1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
2use crate::config::{
3 SemanticBackend, SemanticBackendConfig, DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
4 MAX_SEMANTIC_QUERY_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
5};
6use crate::fs_lock;
7use crate::parser::{detect_language, extract_symbols_from_tree, parse_source_with_cached_parser};
8use crate::search_index::{cache_relative_path, cached_path_under_root};
9use crate::symbols::{Symbol, SymbolKind};
10use crate::synapse_embed::SynapseEmbeddingClient;
11use crate::{slog_info, slog_warn};
12
13use crate::local_embed::LocalEmbedder;
14use rayon::prelude::*;
15use reqwest::blocking::Client;
16use serde::{Deserialize, Serialize};
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::env;
19use std::error::Error;
20use std::fmt::Display;
21use std::fs;
22use std::io::{self, BufReader, BufWriter, Cursor, Read, Write};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex, OnceLock, Weak};
26use std::time::{Duration, Instant, SystemTime};
27use url::Url;
28
29const DEFAULT_DIMENSION: usize = 384;
30const MAX_ENTRIES: usize = 1_000_000;
31const MAX_DIMENSION: usize = 4096;
34const F32_BYTES: usize = std::mem::size_of::<f32>();
35const HEADER_BYTES_V1: usize = 9;
36const HEADER_BYTES_V2: usize = 13;
37const ONNX_RUNTIME_INSTALL_HINT: &str =
38 "ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
39 apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
40 AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
41
42const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
43const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
44const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
49const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
52const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
55const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
57const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
59const DEFAULT_OPENAI_EMBEDDING_PATH: &str = "/embeddings";
60const DEFAULT_OLLAMA_EMBEDDING_PATH: &str = "/api/embed";
61const DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS: u64 = 25_000;
64const DEFAULT_MAX_BATCH_SIZE: usize = 64;
65const QUERY_EMBEDDING_CACHE_CAP: usize = 1_000;
66const FALLBACK_BACKEND: &str = "none";
67const EMBEDDING_REQUEST_MAX_ATTEMPTS: usize = 3;
68const EMBEDDING_REQUEST_BACKOFF_MS: [u64; 2] = [500, 1_000];
69static SEMANTIC_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
70
71#[cfg(test)]
75static MANAGED_ORT_PROBE_READS: AtomicUsize = AtomicUsize::new(0);
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct QueryBudget {
80 timeout_ms: u64,
81}
82
83impl QueryBudget {
84 pub fn from_config(config: &SemanticBackendConfig) -> Self {
85 let configured = if config.query_timeout_ms == 0 {
86 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
87 } else {
88 config.query_timeout_ms
89 };
90 Self {
91 timeout_ms: configured
92 .clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS),
93 }
94 }
95
96 #[cfg(test)]
97 fn timeout_ms(self) -> u64 {
98 self.timeout_ms
99 }
100}
101
102#[derive(Debug, Clone, Copy)]
103enum EmbeddingRequestPolicy {
104 Build,
105 Query(QueryBudget),
106}
107
108impl EmbeddingRequestPolicy {
109 fn max_attempts(self) -> usize {
110 match self {
111 Self::Build => EMBEDDING_REQUEST_MAX_ATTEMPTS,
112 Self::Query(_) => 1,
113 }
114 }
115
116 fn request_timeout(self) -> Option<Duration> {
117 match self {
118 Self::Build => None,
119 Self::Query(budget) => Some(Duration::from_millis(budget.timeout_ms)),
120 }
121 }
122}
123
124pub struct SemanticIndexLock {
125 _guard: Option<fs_lock::LockGuard>,
126}
127
128impl SemanticIndexLock {
129 pub fn acquire(
130 storage_dir: &Path,
131 project_key: &str,
132 project_root: &Path,
133 ) -> std::io::Result<Self> {
134 let dir = storage_dir.join("semantic").join(project_key);
135 let path = dir.join("cache.lock");
136 let access = crate::root_cache::ArtifactAccess::for_root(project_root);
137 if !access.allows_write(project_key, &path) {
138 return Ok(Self { _guard: None });
139 }
140 fs::create_dir_all(&dir)?;
141 let _acquire_guard = SEMANTIC_LOCK_ACQUIRE_MUTEX
142 .lock()
143 .map_err(|_| std::io::Error::other("semantic cache lock acquisition mutex poisoned"))?;
144 fs_lock::try_acquire(&path, Duration::from_secs(2))
145 .map(|guard| Self {
146 _guard: Some(guard),
147 })
148 .map_err(|error| match error {
149 fs_lock::AcquireError::Timeout => {
150 std::io::Error::other("timed out acquiring semantic cache lock")
151 }
152 fs_lock::AcquireError::Io(error) => error,
153 })
154 }
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct SemanticIndexFingerprint {
159 pub backend: String,
160 pub model: String,
161 #[serde(default)]
162 pub base_url: String,
163 pub dimension: usize,
164 #[serde(default = "default_chunking_version")]
165 pub chunking_version: u32,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub synapse_fingerprint: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub synapse_table_epoch: Option<u64>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub synapse_equivalent_to: Vec<String>,
176}
177
178fn default_chunking_version() -> u32 {
179 2
180}
181
182impl SemanticIndexFingerprint {
183 fn from_config(config: &SemanticBackendConfig, dimension: usize) -> Self {
184 let base_url = config
187 .base_url
188 .as_ref()
189 .and_then(|u| normalize_base_url(u).ok())
190 .unwrap_or_else(|| FALLBACK_BACKEND.to_string());
191 Self {
192 backend: config.backend.as_str().to_string(),
193 model: config.model.clone(),
194 base_url,
195 dimension,
196 chunking_version: default_chunking_version(),
197 synapse_fingerprint: None,
198 synapse_table_epoch: None,
199 synapse_equivalent_to: Vec::new(),
200 }
201 }
202
203 pub fn as_string(&self) -> String {
204 serde_json::to_string(self).unwrap_or_else(|_| String::new())
205 }
206
207 pub(crate) fn for_config_dimension(config: &SemanticBackendConfig, dimension: usize) -> Self {
208 Self::from_config(config, dimension)
209 }
210
211 fn matches_expected(&self, expected: &str) -> bool {
212 let Ok(current) = serde_json::from_str::<Self>(expected) else {
213 return false;
214 };
215 self.matches(¤t)
216 }
217
218 fn matches(&self, current: &Self) -> bool {
219 if self.backend != current.backend
220 || self.model != current.model
221 || self.base_url != current.base_url
222 || self.dimension != current.dimension
223 || self.chunking_version != current.chunking_version
224 || self.synapse_table_epoch != current.synapse_table_epoch
225 {
226 return false;
227 }
228 match (&self.synapse_fingerprint, ¤t.synapse_fingerprint) {
229 (None, None) => true,
230 (Some(cached), Some(served)) => {
231 cached == served
232 || current
233 .synapse_equivalent_to
234 .iter()
235 .any(|alias| alias == cached)
236 || self
237 .synapse_equivalent_to
238 .iter()
239 .any(|alias| alias == served)
240 }
241 _ => false,
242 }
243 }
244}
245
246fn redacted_base_url_host(base_url: &str) -> String {
247 if base_url.is_empty() {
248 return "<empty>".to_string();
249 }
250 if base_url == FALLBACK_BACKEND {
251 return FALLBACK_BACKEND.to_string();
252 }
253
254 match Url::parse(base_url) {
255 Ok(parsed) => {
256 let host = parsed.host_str().unwrap_or("<missing-host>");
257 match parsed.port() {
258 Some(port) => format!("{host}:{port}"),
259 None => host.to_string(),
260 }
261 }
262 Err(_) => "<invalid>".to_string(),
263 }
264}
265
266fn format_fingerprint_mismatch_details(
267 cached: Option<&SemanticIndexFingerprint>,
268 current: &SemanticIndexFingerprint,
269) -> String {
270 let Some(cached) = cached else {
271 return format!(
272 "cached fingerprint missing; current backend kind={}, model={}, base_url host={}, dimension={}, chunking version={}",
273 current.backend,
274 current.model,
275 redacted_base_url_host(¤t.base_url),
276 current.dimension,
277 current.chunking_version,
278 );
279 };
280
281 let mut diffs = Vec::new();
282 if cached.backend != current.backend {
283 diffs.push(format!(
284 "backend kind cached={} current={}",
285 cached.backend, current.backend
286 ));
287 }
288 if cached.model != current.model {
289 diffs.push(format!(
290 "model cached={} current={}",
291 cached.model, current.model
292 ));
293 }
294 if cached.base_url != current.base_url {
295 let cached_host = redacted_base_url_host(&cached.base_url);
296 let current_host = redacted_base_url_host(¤t.base_url);
297 if cached_host == current_host {
298 diffs.push(format!(
299 "base_url host cached={} current={} (credentials/path redacted)",
300 cached_host, current_host
301 ));
302 } else {
303 diffs.push(format!(
304 "base_url host cached={} current={}",
305 cached_host, current_host
306 ));
307 }
308 }
309 if cached.dimension != current.dimension {
310 diffs.push(format!(
311 "dimension cached={} current={}",
312 cached.dimension, current.dimension
313 ));
314 }
315 if cached.chunking_version != current.chunking_version {
316 diffs.push(format!(
317 "chunking version cached={} current={}",
318 cached.chunking_version, current.chunking_version
319 ));
320 }
321 if cached.synapse_table_epoch != current.synapse_table_epoch {
322 diffs.push(format!(
323 "synapse table_epoch cached={:?} current={:?}",
324 cached.synapse_table_epoch, current.synapse_table_epoch
325 ));
326 }
327 if !cached.matches(current)
328 && (cached.synapse_fingerprint.is_some() || current.synapse_fingerprint.is_some())
329 {
330 diffs.push(format!(
331 "synapse fingerprint cached={} current={} (equivalence class checked)",
332 cached.synapse_fingerprint.as_deref().unwrap_or("<missing>"),
333 current
334 .synapse_fingerprint
335 .as_deref()
336 .unwrap_or("<missing>")
337 ));
338 }
339
340 if diffs.is_empty() {
341 "fingerprint strings differ but parsed fields match".to_string()
342 } else {
343 diffs.join("; ")
344 }
345}
346
347fn log_fingerprint_mismatch(cached: Option<&SemanticIndexFingerprint>, expected: &str) {
348 match serde_json::from_str::<SemanticIndexFingerprint>(expected) {
349 Ok(current) => slog_warn!(
350 "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: {}",
351 format_fingerprint_mismatch_details(cached, ¤t)
352 ),
353 Err(error) => slog_warn!(
354 "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: could not parse current fingerprint: {}",
355 error
356 ),
357 }
358}
359
360enum SemanticEmbeddingEngine {
361 Local(LocalEmbedder),
364 OpenAiCompatible {
365 client: Client,
366 model: String,
367 base_url: String,
368 api_key: Option<String>,
369 },
370 Ollama {
371 client: Client,
372 model: String,
373 base_url: String,
374 },
375 Synapse(SynapseEmbeddingClient),
376}
377
378pub struct SemanticEmbeddingModel {
379 backend: SemanticBackend,
380 model: String,
381 base_url: Option<String>,
382 timeout_ms: u64,
383 max_batch_size: usize,
384 dimension: Option<usize>,
385 engine: SemanticEmbeddingEngine,
386 query_embedding_cache: HashMap<String, Vec<f32>>,
387 query_embedding_cache_order: VecDeque<String>,
388 query_embedding_cache_hits: u64,
389 query_embedding_cache_misses: u64,
390}
391
392pub type EmbeddingModel = SemanticEmbeddingModel;
393
394fn validate_embedding_batch(
395 vectors: &[Vec<f32>],
396 expected_count: usize,
397 context: &str,
398) -> Result<(), String> {
399 if expected_count > 0 && vectors.is_empty() {
400 return Err(format!(
401 "{context} returned no vectors for {expected_count} inputs"
402 ));
403 }
404
405 if vectors.len() != expected_count {
406 return Err(format!(
407 "{context} returned {} vectors for {} inputs",
408 vectors.len(),
409 expected_count
410 ));
411 }
412
413 let Some(first_vector) = vectors.first() else {
414 return Ok(());
415 };
416 let expected_dimension = first_vector.len();
417 validate_embedding_dimension(expected_dimension)
418 .map_err(|error| format!("{context} returned {error}"))?;
419 for (index, vector) in vectors.iter().enumerate() {
420 if vector.len() != expected_dimension {
421 return Err(format!(
422 "{context} returned inconsistent embedding dimensions: vector 0 has length {expected_dimension}, vector {index} has length {}",
423 vector.len()
424 ));
425 }
426 }
427
428 Ok(())
429}
430
431fn validate_embedding_dimension(dimension: usize) -> Result<(), String> {
432 if dimension == 0 || dimension > MAX_DIMENSION {
433 return Err(format!(
434 "invalid embedding dimension: {dimension}; supported range is 1..={MAX_DIMENSION}"
435 ));
436 }
437
438 Ok(())
439}
440
441fn normalize_base_url(raw: &str) -> Result<String, String> {
445 let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
446 let scheme = parsed.scheme();
447 if scheme != "http" && scheme != "https" {
448 return Err(format!(
449 "unsupported URL scheme '{}' — only http:// and https:// are allowed",
450 scheme
451 ));
452 }
453 Ok(parsed.to_string().trim_end_matches('/').to_string())
454}
455
456pub fn validate_base_url_no_ssrf(raw: &str) -> Result<(), String> {
471 use std::net::{IpAddr, ToSocketAddrs};
472
473 let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
474
475 let host = parsed.host_str().unwrap_or("");
476
477 let is_loopback_host =
482 host == "localhost" || host == "localhost.localdomain" || host.ends_with(".localhost");
483 if is_loopback_host {
484 return Ok(());
485 }
486
487 if host.ends_with(".local") {
490 return Err(format!(
491 "base_url host '{host}' is an mDNS name — only loopback (localhost / 127.0.0.1) and public endpoints are allowed"
492 ));
493 }
494
495 let port = parsed.port_or_known_default().unwrap_or(443);
498 let addr_str = format!("{host}:{port}");
499 let addrs: Vec<IpAddr> = addr_str
500 .to_socket_addrs()
501 .map(|iter| iter.map(|sa| sa.ip()).collect())
502 .unwrap_or_default();
503 for ip in &addrs {
504 if is_private_non_loopback_ip(ip) {
505 return Err(format!(
506 "base_url '{raw}' resolves to a private/reserved IP — only loopback (127.0.0.1) and public endpoints are allowed"
507 ));
508 }
509 }
510
511 Ok(())
512}
513
514fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
525 if ip.to_canonical().is_loopback() {
528 return false;
529 }
530 crate::url_fetch::is_private_or_reserved_ip(*ip)
531}
532
533fn build_openai_embeddings_endpoint(base_url: &str) -> String {
534 if base_url.ends_with("/v1") {
535 format!("{base_url}{DEFAULT_OPENAI_EMBEDDING_PATH}")
536 } else {
537 format!("{base_url}/v1{}", DEFAULT_OPENAI_EMBEDDING_PATH)
538 }
539}
540
541fn build_ollama_embeddings_endpoint(base_url: &str) -> String {
542 if base_url.ends_with("/api") {
543 format!("{base_url}/embed")
544 } else {
545 format!("{base_url}{DEFAULT_OLLAMA_EMBEDDING_PATH}")
546 }
547}
548
549fn normalize_api_key(value: Option<String>) -> Option<String> {
550 value.and_then(|token| {
551 let token = token.trim();
552 if token.is_empty() {
553 None
554 } else {
555 Some(token.to_string())
556 }
557 })
558}
559
560fn is_retryable_embedding_status(status: reqwest::StatusCode) -> bool {
561 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
562}
563
564fn embedding_response_body_is_transient(status: reqwest::StatusCode, raw: &str) -> bool {
570 if !matches!(
571 status,
572 reqwest::StatusCode::BAD_REQUEST
573 | reqwest::StatusCode::CONFLICT
574 | reqwest::StatusCode::REQUEST_TIMEOUT
575 | reqwest::StatusCode::LOCKED
576 | reqwest::StatusCode::TOO_EARLY
577 ) {
578 return false;
579 }
580
581 let lower = raw.to_ascii_lowercase();
582 let normalized = lower.trim();
583
584 normalized.contains("model was unloaded while the request was still in queue")
585 || normalized == "model is loading"
586 || normalized.starts_with("model is loading,")
587 || normalized.contains(r#""error":"model is loading"#)
588 || normalized.contains(r#""message":"model is loading"#)
589 || normalized == "model not loaded"
590 || normalized.contains(r#""error":"model not loaded""#)
591 || normalized.contains(r#""message":"model not loaded""#)
592 || normalized == "loading model into memory"
593 || normalized.contains(r#""error":"loading model into memory""#)
594 || normalized.contains(r#""message":"loading model into memory""#)
595 || normalized == "model is being loaded"
596 || normalized.contains(r#""error":"model is being loaded""#)
597 || normalized.contains(r#""message":"model is being loaded""#)
598 || normalized == "model is currently loading"
599 || normalized.contains(r#""error":"model is currently loading""#)
600 || normalized.contains(r#""message":"model is currently loading""#)
601}
602
603fn is_retryable_embedding_error(error: &reqwest::Error) -> bool {
604 embedding_send_error_is_transient(error)
607}
608
609fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
614 if embedding_error_is_certificate_trust_failure(error) {
618 return false;
619 }
620 if error.is_connect() || error.is_timeout() {
621 return true;
622 }
623 let mut source = std::error::Error::source(error);
632 while let Some(inner) = source {
633 if let Some(io) = inner.downcast_ref::<std::io::Error>() {
634 if matches!(
635 io.kind(),
636 std::io::ErrorKind::ConnectionReset
637 | std::io::ErrorKind::ConnectionAborted
638 | std::io::ErrorKind::BrokenPipe
639 | std::io::ErrorKind::UnexpectedEof
640 ) {
641 return true;
642 }
643 }
644 let rendered = inner.to_string().to_ascii_lowercase();
645 if rendered.contains("connection reset")
646 || rendered.contains("connection aborted")
647 || rendered.contains("connection closed")
648 || rendered.contains("broken pipe")
649 || rendered.contains("unexpected end of file")
650 {
651 return true;
652 }
653 source = std::error::Error::source(inner);
654 }
655 false
656}
657
658fn render_error_source_chain(error: &dyn Error) -> String {
659 let mut rendered = error.to_string();
660 let mut source = error.source();
661 while let Some(cause) = source {
662 rendered.push_str(": ");
663 rendered.push_str(&cause.to_string());
664 source = cause.source();
665 }
666 rendered
667}
668
669fn embedding_error_is_certificate_trust_failure(error: &reqwest::Error) -> bool {
670 let rendered = render_error_source_chain(error).to_ascii_lowercase();
671 [
672 "unknownissuer",
673 "unknown issuer",
674 "invalid peer certificate",
675 "certificate verify failed",
676 "certificate validation failed",
677 "certificate error",
678 ]
679 .iter()
680 .any(|marker| rendered.contains(marker))
681}
682
683fn embedding_response_read_error_is_transient(error: &reqwest::Error) -> bool {
684 embedding_send_error_is_transient(error) || error.is_body() || error.is_decode()
685}
686
687fn query_timeout_marker_for_error(
694 error: &reqwest::Error,
695 policy: EmbeddingRequestPolicy,
696) -> String {
697 match policy {
698 EmbeddingRequestPolicy::Query(budget) if error.is_timeout() => {
699 query_embedding_timeout_marker(budget.timeout_ms)
700 }
701 _ => String::new(),
702 }
703}
704
705pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
712
713pub fn embedding_failure_is_transient(error: &str) -> bool {
716 error.contains(TRANSIENT_EMBEDDING_MARKER)
717}
718
719pub fn strip_transient_embedding_marker(error: &str) -> String {
721 error.replace(TRANSIENT_EMBEDDING_MARKER, "")
722}
723
724pub const QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[query-timeout:";
737pub const QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
738
739pub(crate) fn query_embedding_timeout_marker(timeout_ms: u64) -> String {
744 format!("{QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX}{timeout_ms}{QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX}")
745}
746
747pub fn query_embedding_timeout_budget(error: &str) -> Option<u64> {
751 let start = error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX)?;
752 let rest = &error[start + QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX.len()..];
753 let end = rest.find(QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX)?;
754 rest[..end].parse::<u64>().ok()
755}
756
757pub fn strip_query_embedding_timeout_marker(error: &str) -> String {
761 if let (Some(start), Some(budget)) = (
762 error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX),
763 query_embedding_timeout_budget(error),
764 ) {
765 let marker = query_embedding_timeout_marker(budget);
766 let end = start + marker.len();
767 let mut cleaned = error.to_string();
768 cleaned.replace_range(start..end, "");
769 cleaned
770 } else {
771 error.to_string()
772 }
773}
774
775fn sleep_before_embedding_retry(attempt_index: usize) {
776 if let Some(delay_ms) = EMBEDDING_REQUEST_BACKOFF_MS.get(attempt_index) {
777 std::thread::sleep(Duration::from_millis(*delay_ms));
778 }
779}
780
781const QUERY_EMBEDDING_CANCELLED_MARKER: &str = "__AFT_QUERY_EMBEDDING_CANCELLED__";
782const QUERY_EMBEDDING_CANCEL_POLL: Duration = Duration::from_millis(10);
783
784enum EmbeddingExchange {
785 SendFailed(reqwest::Error),
786 Response {
787 status: reqwest::StatusCode,
788 body: Result<String, reqwest::Error>,
789 },
790}
791
792fn execute_embedding_exchange(request: reqwest::blocking::RequestBuilder) -> EmbeddingExchange {
793 match request.send() {
794 Ok(response) => EmbeddingExchange::Response {
795 status: response.status(),
796 body: response.text(),
797 },
798 Err(error) => EmbeddingExchange::SendFailed(error),
799 }
800}
801
802fn execute_query_embedding_exchange(
803 request: reqwest::blocking::RequestBuilder,
804) -> Result<EmbeddingExchange, String> {
805 let Some(cancellation) = crate::executor::current_job_cancellation() else {
806 return Ok(execute_embedding_exchange(request));
807 };
808 if cancellation.cancel_requested_before_commit() {
809 return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
810 }
811
812 let (tx, rx) = crossbeam_channel::bounded(1);
813 std::thread::spawn(move || {
814 let _ = tx.send(execute_embedding_exchange(request));
815 });
816 loop {
817 match rx.try_recv() {
818 Ok(exchange) => {
819 if cancellation.cancel_requested_before_commit() {
820 return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
821 }
822 return Ok(exchange);
823 }
824 Err(crossbeam_channel::TryRecvError::Disconnected) => {
825 return Err("embedding request worker disconnected".to_string());
826 }
827 Err(crossbeam_channel::TryRecvError::Empty) => {}
828 }
829 if cancellation.wait_for_cancellation(QUERY_EMBEDDING_CANCEL_POLL) {
830 return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
831 }
832 }
833}
834
835fn send_embedding_request<F>(
836 mut make_request: F,
837 backend_label: &str,
838 policy: EmbeddingRequestPolicy,
839) -> Result<String, String>
840where
841 F: FnMut() -> reqwest::blocking::RequestBuilder,
842{
843 let max_attempts = policy.max_attempts();
844 for attempt_index in 0..max_attempts {
845 let last_attempt = attempt_index + 1 == max_attempts;
846 let mut request = make_request();
847 if let Some(timeout) = policy.request_timeout() {
848 request = request.timeout(timeout);
849 }
850
851 let exchange = match policy {
852 EmbeddingRequestPolicy::Build => execute_embedding_exchange(request),
853 EmbeddingRequestPolicy::Query(_) => execute_query_embedding_exchange(request)?,
854 };
855 let (status, raw) = match exchange {
856 EmbeddingExchange::SendFailed(error) => {
857 if !last_attempt && is_retryable_embedding_error(&error) {
858 sleep_before_embedding_retry(attempt_index);
859 continue;
860 }
861 let marker = if embedding_send_error_is_transient(&error) {
865 TRANSIENT_EMBEDDING_MARKER
866 } else {
867 ""
868 };
869 let timeout_marker = query_timeout_marker_for_error(&error, policy);
875 return Err(format!(
876 "{timeout_marker}{marker}{backend_label} request failed: {}",
877 render_error_source_chain(&error)
878 ));
879 }
880 EmbeddingExchange::Response {
881 status,
882 body: Ok(raw),
883 } => (status, raw),
884 EmbeddingExchange::Response {
885 status: _,
886 body: Err(error),
887 } => {
888 if !last_attempt && embedding_response_read_error_is_transient(&error) {
889 sleep_before_embedding_retry(attempt_index);
890 continue;
891 }
892 let marker = if embedding_response_read_error_is_transient(&error) {
893 TRANSIENT_EMBEDDING_MARKER
894 } else {
895 ""
896 };
897 let timeout_marker = query_timeout_marker_for_error(&error, policy);
900 return Err(format!(
901 "{timeout_marker}{marker}{backend_label} response read failed: {}",
902 render_error_source_chain(&error)
903 ));
904 }
905 };
906
907 if status.is_success() {
908 return Ok(raw);
909 }
910
911 let body_transient = embedding_response_body_is_transient(status, &raw);
915 if !last_attempt && (is_retryable_embedding_status(status) || body_transient) {
916 sleep_before_embedding_retry(attempt_index);
917 continue;
918 }
919
920 let marker = if is_retryable_embedding_status(status) || body_transient {
926 TRANSIENT_EMBEDDING_MARKER
927 } else {
928 ""
929 };
930 return Err(format!(
931 "{marker}{backend_label} request failed (HTTP {}): {}",
932 status, raw
933 ));
934 }
935
936 unreachable!("embedding request retries exhausted without returning")
937}
938
939fn configured_embedding_timeout_ms(config: &SemanticBackendConfig) -> u64 {
940 if config.timeout_ms == 0 {
941 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS
942 } else {
943 config.timeout_ms
944 }
945}
946
947impl SemanticEmbeddingModel {
948 pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, String> {
949 Self::from_config_with_timeout_ms(config, configured_embedding_timeout_ms(config))
950 }
951
952 pub fn from_config_for_query(config: &SemanticBackendConfig) -> Result<Self, String> {
953 Self::from_config(config)
956 }
957
958 fn from_config_with_timeout_ms(
959 config: &SemanticBackendConfig,
960 timeout_ms: u64,
961 ) -> Result<Self, String> {
962 let max_batch_size = if config.max_batch_size == 0 {
963 DEFAULT_MAX_BATCH_SIZE
964 } else {
965 config.max_batch_size
966 };
967
968 let api_key_env = normalize_api_key(config.api_key_env.clone());
969 let model = config.model.clone();
970
971 let tls_config = crate::platform_tls::client_config()
972 .map_err(|error| format!("failed to configure embedding client TLS: {error}"))?;
973 let client = Client::builder()
974 .timeout(Duration::from_millis(timeout_ms))
975 .redirect(reqwest::redirect::Policy::none())
976 .use_preconfigured_tls(tls_config)
977 .build()
978 .map_err(|error| format!("failed to configure embedding client: {error}"))?;
979
980 let engine = match config.backend {
981 SemanticBackend::Fastembed => {
982 SemanticEmbeddingEngine::Local(LocalEmbedder::new(&model)?)
983 }
984 SemanticBackend::OpenAiCompatible => {
985 let raw = config.base_url.as_ref().ok_or_else(|| {
986 "base_url is required for openai_compatible backend".to_string()
987 })?;
988 let base_url = normalize_base_url(raw)?;
989
990 let api_key = match api_key_env {
991 Some(var_name) => Some(env::var(&var_name).map_err(|_| {
992 format!("missing api_key_env '{var_name}' for openai_compatible backend")
993 })?),
994 None => None,
995 };
996
997 SemanticEmbeddingEngine::OpenAiCompatible {
998 client,
999 model,
1000 base_url,
1001 api_key,
1002 }
1003 }
1004 SemanticBackend::Ollama => {
1005 let raw = config
1006 .base_url
1007 .as_ref()
1008 .ok_or_else(|| "base_url is required for ollama backend".to_string())?;
1009 let base_url = normalize_base_url(raw)?;
1010
1011 SemanticEmbeddingEngine::Ollama {
1012 client,
1013 model,
1014 base_url,
1015 }
1016 }
1017 SemanticBackend::Synapse => SemanticEmbeddingEngine::Synapse(
1018 SynapseEmbeddingClient::from_config(config).map_err(|error| error.to_string())?,
1019 ),
1020 };
1021 let max_batch_size = match &engine {
1022 SemanticEmbeddingEngine::Synapse(client) => client.metadata().recommended_rows,
1023 _ => max_batch_size,
1024 };
1025
1026 Ok(Self {
1027 backend: config.backend,
1028 model: config.model.clone(),
1029 base_url: config.base_url.clone(),
1030 timeout_ms,
1031 max_batch_size,
1032 dimension: None,
1033 engine,
1034 query_embedding_cache: HashMap::new(),
1035 query_embedding_cache_order: VecDeque::new(),
1036 query_embedding_cache_hits: 0,
1037 query_embedding_cache_misses: 0,
1038 })
1039 }
1040
1041 pub fn backend(&self) -> SemanticBackend {
1042 self.backend
1043 }
1044
1045 pub fn model(&self) -> &str {
1046 &self.model
1047 }
1048
1049 pub fn base_url(&self) -> Option<&str> {
1050 self.base_url.as_deref()
1051 }
1052
1053 pub fn max_batch_size(&self) -> usize {
1054 self.max_batch_size
1055 }
1056
1057 pub fn timeout_ms(&self) -> u64 {
1058 self.timeout_ms
1059 }
1060
1061 pub fn fingerprint(
1062 &mut self,
1063 config: &SemanticBackendConfig,
1064 ) -> Result<SemanticIndexFingerprint, String> {
1065 let dimension = self.dimension()?;
1066 let mut fingerprint = SemanticIndexFingerprint::from_config(config, dimension);
1067 if let SemanticEmbeddingEngine::Synapse(client) = &self.engine {
1068 let identity = client.identity();
1069 fingerprint.synapse_fingerprint = Some(identity.fingerprint.clone());
1070 fingerprint.synapse_table_epoch = Some(identity.table_epoch);
1071 fingerprint.synapse_equivalent_to = identity.equivalent_to.clone();
1072 }
1073 Ok(fingerprint)
1074 }
1075
1076 pub fn dimension(&mut self) -> Result<usize, String> {
1077 if let Some(dimension) = self.dimension {
1078 return Ok(dimension);
1079 }
1080
1081 let dimension = match &mut self.engine {
1082 SemanticEmbeddingEngine::Local(model) => {
1083 let vectors = model.embed(&["semantic index fingerprint probe".to_string()])?;
1084 vectors
1085 .first()
1086 .map(|v| v.len())
1087 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1088 }
1089 SemanticEmbeddingEngine::OpenAiCompatible { .. } => {
1090 let vectors = self.embed_texts(
1091 vec!["semantic index fingerprint probe".to_string()],
1092 EmbeddingRequestPolicy::Build,
1093 )?;
1094 vectors
1095 .first()
1096 .map(|v| v.len())
1097 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1098 }
1099 SemanticEmbeddingEngine::Ollama { .. } => {
1100 let vectors = self.embed_texts(
1101 vec!["semantic index fingerprint probe".to_string()],
1102 EmbeddingRequestPolicy::Build,
1103 )?;
1104 vectors
1105 .first()
1106 .map(|v| v.len())
1107 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1108 }
1109 SemanticEmbeddingEngine::Synapse(client) => client
1110 .probe_dimension(Duration::from_millis(self.timeout_ms))
1111 .map_err(|error| error.to_string())?,
1112 };
1113
1114 self.dimension = Some(dimension);
1115 Ok(dimension)
1116 }
1117
1118 pub fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1119 self.embed_texts(texts, EmbeddingRequestPolicy::Build)
1120 }
1121
1122 pub fn embed_query_cached(
1123 &mut self,
1124 query: &str,
1125 budget: QueryBudget,
1126 ) -> Result<Vec<f32>, String> {
1127 if let Some(vector) = self.query_embedding_cache.get(query) {
1128 self.query_embedding_cache_hits += 1;
1129 return Ok(vector.clone());
1130 }
1131
1132 self.query_embedding_cache_misses += 1;
1133 let embeddings = self.embed_texts(
1134 vec![query.to_string()],
1135 EmbeddingRequestPolicy::Query(budget),
1136 )?;
1137 let vector = embeddings
1138 .first()
1139 .cloned()
1140 .ok_or_else(|| "embedding model returned no query vector".to_string())?;
1141
1142 if self.query_embedding_cache.len() >= QUERY_EMBEDDING_CACHE_CAP {
1143 if let Some(oldest) = self.query_embedding_cache_order.pop_front() {
1144 self.query_embedding_cache.remove(&oldest);
1145 }
1146 }
1147 self.query_embedding_cache
1148 .insert(query.to_string(), vector.clone());
1149 self.query_embedding_cache_order
1150 .push_back(query.to_string());
1151
1152 Ok(vector)
1153 }
1154
1155 pub fn query_embedding_cache_stats(&self) -> (u64, u64, usize) {
1156 (
1157 self.query_embedding_cache_hits,
1158 self.query_embedding_cache_misses,
1159 self.query_embedding_cache.len(),
1160 )
1161 }
1162
1163 fn embed_texts(
1164 &mut self,
1165 texts: Vec<String>,
1166 policy: EmbeddingRequestPolicy,
1167 ) -> Result<Vec<Vec<f32>>, String> {
1168 match &mut self.engine {
1169 SemanticEmbeddingEngine::Local(model) => model
1170 .embed(&texts)
1171 .map_err(|error| format!("failed to embed batch: {error}")),
1172 SemanticEmbeddingEngine::OpenAiCompatible {
1173 client,
1174 model,
1175 base_url,
1176 api_key,
1177 } => {
1178 let expected_text_count = texts.len();
1179 let endpoint = build_openai_embeddings_endpoint(base_url);
1180 let body = serde_json::json!({
1181 "input": texts,
1182 "model": model,
1183 });
1184
1185 let raw = send_embedding_request(
1186 || {
1187 let mut request = client.post(&endpoint).json(&body);
1197
1198 if let Some(api_key) = api_key {
1199 request = request.header("Authorization", format!("Bearer {api_key}"));
1200 }
1201
1202 request
1203 },
1204 "openai compatible",
1205 policy,
1206 )?;
1207
1208 #[derive(Deserialize)]
1209 struct OpenAiResponse {
1210 data: Vec<OpenAiEmbeddingResult>,
1211 }
1212
1213 #[derive(Deserialize)]
1214 struct OpenAiEmbeddingResult {
1215 embedding: Vec<f32>,
1216 index: Option<u32>,
1217 }
1218
1219 let parsed: OpenAiResponse = serde_json::from_str(&raw)
1220 .map_err(|error| format!("invalid openai compatible response: {error}"))?;
1221 if parsed.data.len() != expected_text_count {
1222 return Err(format!(
1223 "openai compatible response returned {} embeddings for {} inputs",
1224 parsed.data.len(),
1225 expected_text_count
1226 ));
1227 }
1228
1229 let mut vectors = vec![Vec::new(); parsed.data.len()];
1230 for (i, item) in parsed.data.into_iter().enumerate() {
1231 let index = item.index.unwrap_or(i as u32) as usize;
1232 if index >= vectors.len() {
1233 return Err(
1234 "openai compatible response contains invalid vector index".to_string()
1235 );
1236 }
1237 vectors[index] = item.embedding;
1238 }
1239
1240 for vector in &vectors {
1241 if vector.is_empty() {
1242 return Err(
1243 "openai compatible response contained missing vectors".to_string()
1244 );
1245 }
1246 }
1247
1248 self.dimension = vectors.first().map(Vec::len);
1249 Ok(vectors)
1250 }
1251 SemanticEmbeddingEngine::Ollama {
1252 client,
1253 model,
1254 base_url,
1255 } => {
1256 let expected_text_count = texts.len();
1257 let endpoint = build_ollama_embeddings_endpoint(base_url);
1258
1259 #[derive(Serialize)]
1260 struct OllamaPayload<'a> {
1261 model: &'a str,
1262 input: Vec<String>,
1263 }
1264
1265 let payload = OllamaPayload {
1266 model,
1267 input: texts,
1268 };
1269
1270 let raw = send_embedding_request(
1271 || {
1272 client.post(&endpoint).json(&payload)
1277 },
1278 "ollama",
1279 policy,
1280 )?;
1281
1282 #[derive(Deserialize)]
1283 struct OllamaResponse {
1284 embeddings: Vec<Vec<f32>>,
1285 }
1286
1287 let parsed: OllamaResponse = serde_json::from_str(&raw)
1288 .map_err(|error| format!("invalid ollama response: {error}"))?;
1289 if parsed.embeddings.is_empty() {
1290 return Err("ollama response returned no embeddings".to_string());
1291 }
1292 if parsed.embeddings.len() != expected_text_count {
1293 return Err(format!(
1294 "ollama response returned {} embeddings for {} inputs",
1295 parsed.embeddings.len(),
1296 expected_text_count
1297 ));
1298 }
1299
1300 let vectors = parsed.embeddings;
1301 for vector in &vectors {
1302 if vector.is_empty() {
1303 return Err("ollama response contained empty embeddings".to_string());
1304 }
1305 }
1306
1307 self.dimension = vectors.first().map(Vec::len);
1308 Ok(vectors)
1309 }
1310 SemanticEmbeddingEngine::Synapse(client) => {
1311 let vectors = match policy {
1312 EmbeddingRequestPolicy::Build => client
1313 .embed_batch(&texts)
1314 .map_err(|error| error.to_string())?,
1315 EmbeddingRequestPolicy::Query(budget) => {
1316 let timeout = Duration::from_millis(budget.timeout_ms);
1317 texts
1318 .iter()
1319 .map(|text| client.embed_query(text, timeout))
1320 .collect::<Result<Vec<_>, _>>()
1321 .map_err(|error| error.to_string())?
1322 }
1323 };
1324 self.dimension = vectors.first().map(Vec::len);
1325 Ok(vectors)
1326 }
1327 }
1328 }
1329}
1330
1331#[cfg(target_os = "linux")]
1338const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.so";
1339#[cfg(target_os = "macos")]
1340const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.dylib";
1341#[cfg(target_os = "windows")]
1342const MANAGED_ORT_LIB_NAME: &str = "onnxruntime.dll";
1343
1344const MANAGED_ORT_MIN_MINOR: u32 = 20;
1350
1351pub fn resolve_managed_onnx_runtime(storage_dir: &Path) {
1378 if std::env::var_os("ORT_DYLIB_PATH").is_some() {
1379 return;
1380 }
1381 let Some(lib_path) = find_managed_onnx_runtime(storage_dir) else {
1382 return;
1383 };
1384 std::env::set_var("ORT_DYLIB_PATH", &lib_path);
1385 slog_info!(
1386 "using plugin-managed ONNX Runtime at {}",
1387 lib_path.display()
1388 );
1389}
1390
1391fn find_managed_onnx_runtime(storage_dir: &Path) -> Option<PathBuf> {
1398 let base = storage_dir.join("onnxruntime");
1399 let entries = std::fs::read_dir(&base).ok()?;
1400 #[cfg(test)]
1401 {
1402 MANAGED_ORT_PROBE_READS.fetch_add(1, Ordering::Relaxed);
1406 }
1407 let mut best: Option<(u32, u32, PathBuf)> = None;
1408 for entry in entries.flatten() {
1409 let path = entry.path();
1410 if !path.is_dir() {
1411 continue;
1412 }
1413 let Some((major, minor)) = parse_managed_ort_version(&entry.file_name().to_string_lossy())
1414 else {
1415 continue;
1416 };
1417 if major != 1 || minor < MANAGED_ORT_MIN_MINOR {
1418 continue;
1419 }
1420 let Some(lib_path) = managed_ort_lib_in_version_dir(&path) else {
1421 continue;
1422 };
1423 if best
1424 .as_ref()
1425 .is_none_or(|(best_major, best_minor, _)| (major, minor) > (*best_major, *best_minor))
1426 {
1427 best = Some((major, minor, lib_path));
1428 }
1429 }
1430 best.map(|(_, _, path)| path)
1431}
1432
1433fn managed_ort_lib_in_version_dir(version_dir: &Path) -> Option<PathBuf> {
1436 let root = version_dir.join(MANAGED_ORT_LIB_NAME);
1437 if root.is_file() {
1438 return Some(root);
1439 }
1440 let lib_subdir = version_dir.join("lib").join(MANAGED_ORT_LIB_NAME);
1441 if lib_subdir.is_file() {
1442 return Some(lib_subdir);
1443 }
1444 None
1445}
1446
1447fn parse_managed_ort_version(name: &str) -> Option<(u32, u32)> {
1451 let mut parts = name.split('.');
1452 let major = parts.next()?.parse::<u32>().ok()?;
1453 let minor = parts.next()?.parse::<u32>().ok()?;
1454 let _patch = parts.next()?.parse::<u32>().ok()?;
1455 if parts.next().is_some() {
1457 return None;
1458 }
1459 Some((major, minor))
1460}
1461
1462pub fn pre_validate_onnx_runtime() -> Result<(), String> {
1466 let dylib_path = std::env::var("ORT_DYLIB_PATH").ok();
1467
1468 #[cfg(any(target_os = "linux", target_os = "macos"))]
1469 {
1470 #[cfg(target_os = "linux")]
1471 let default_name = "libonnxruntime.so";
1472 #[cfg(target_os = "macos")]
1473 let default_name = "libonnxruntime.dylib";
1474
1475 let lib_name = dylib_path.as_deref().unwrap_or(default_name);
1476
1477 unsafe {
1478 let c_name = std::ffi::CString::new(lib_name)
1479 .map_err(|e| format!("invalid library path: {}", e))?;
1480 let handle = libc::dlopen(c_name.as_ptr(), libc::RTLD_NOW);
1481 if handle.is_null() {
1482 let err = libc::dlerror();
1483 let msg = if err.is_null() {
1484 "unknown dlopen error".to_string()
1485 } else {
1486 std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
1487 };
1488 return Err(format!(
1489 "ONNX Runtime not found. dlopen('{}') failed: {}. \
1490 Run `npx @cortexkit/aft doctor` to diagnose.",
1491 lib_name, msg
1492 ));
1493 }
1494
1495 let (detected_version, version_source) =
1500 detect_ort_version_from_loaded_library(handle, lib_name);
1501
1502 libc::dlclose(handle);
1503
1504 if let Some(ref version) = detected_version {
1506 let parts: Vec<&str> = version.split('.').collect();
1507 if let (Some(major), Some(minor)) = (
1508 parts.first().and_then(|s| s.parse::<u32>().ok()),
1509 parts.get(1).and_then(|s| s.parse::<u32>().ok()),
1510 ) {
1511 if major != 1 || minor < 20 {
1512 return Err(format_ort_version_mismatch(version, &version_source));
1513 }
1514 }
1515 }
1516 }
1517 }
1518
1519 #[cfg(target_os = "windows")]
1520 {
1521 let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
1526
1527 #[link(name = "kernel32")]
1531 extern "system" {
1532 fn LoadLibraryExW(
1533 lpLibFileName: *const u16,
1534 hFile: *mut std::ffi::c_void,
1535 dwFlags: u32,
1536 ) -> *mut std::ffi::c_void;
1537 fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
1538 fn GetModuleFileNameW(
1539 hModule: *mut std::ffi::c_void,
1540 lpFilename: *mut u16,
1541 nSize: u32,
1542 ) -> u32;
1543 }
1544
1545 #[link(name = "version")]
1546 extern "system" {
1547 fn GetFileVersionInfoSizeW(lptstrFilename: *const u16, lpdwHandle: *mut u32) -> u32;
1548 fn GetFileVersionInfoW(
1549 lptstrFilename: *const u16,
1550 dwHandle: u32,
1551 dwLen: u32,
1552 lpData: *mut std::ffi::c_void,
1553 ) -> i32;
1554 fn VerQueryValueW(
1555 pBlock: *mut std::ffi::c_void,
1556 lpSubBlock: *const u16,
1557 lplpBuffer: *mut *mut std::ffi::c_void,
1558 puLen: *mut u32,
1559 ) -> i32;
1560 }
1561
1562 #[repr(C)]
1563 struct VS_FIXEDFILEINFO {
1564 dw_signature: u32,
1565 dw_struc_version: u32,
1566 dw_file_version_ms: u32, dw_file_version_ls: u32, dw_product_version_ms: u32,
1569 dw_product_version_ls: u32,
1570 dw_file_flags_mask: u32,
1571 dw_file_flags: u32,
1572 dw_file_os: u32,
1573 dw_file_type: u32,
1574 dw_file_subtype: u32,
1575 dw_file_date_ms: u32,
1576 dw_file_date_ls: u32,
1577 }
1578
1579 unsafe {
1580 use std::os::windows::ffi::OsStrExt;
1581 let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
1582 .encode_wide()
1583 .chain(std::iter::once(0))
1584 .collect();
1585
1586 let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
1587 if handle.is_null() {
1588 let err = std::io::Error::last_os_error();
1589 return Err(format!(
1590 "ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
1591 Run `npx @cortexkit/aft doctor` to diagnose.",
1592 lib_name, err
1593 ));
1594 }
1595
1596 let mut detected_major: u32 = 0;
1599 let mut detected_minor: u32 = 0;
1600 let mut path_buf = [0u16; 32767];
1606 let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 32767);
1607 if path_len > 0 {
1608 let mut dummy_handle: u32 = 0;
1609 let info_size = GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
1610 if info_size > 0 {
1611 let mut info = vec![0u8; info_size as usize];
1612 if GetFileVersionInfoW(
1613 path_buf.as_ptr(),
1614 0,
1615 info_size,
1616 info.as_mut_ptr() as *mut std::ffi::c_void,
1617 ) != 0
1618 {
1619 let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
1620 let mut vs_info: *mut std::ffi::c_void = std::ptr::null_mut();
1621 let mut vs_len: u32 = 0;
1622 if VerQueryValueW(
1623 info.as_mut_ptr() as *mut std::ffi::c_void,
1624 sub_block.as_ptr(),
1625 &mut vs_info,
1626 &mut vs_len,
1627 ) != 0
1628 && !vs_info.is_null()
1629 {
1630 let fixed = vs_info as *const VS_FIXEDFILEINFO;
1631 detected_major = (*fixed).dw_file_version_ms >> 16;
1632 detected_minor = (*fixed).dw_file_version_ms & 0xFFFF;
1633 }
1634 }
1635 }
1636 }
1637
1638 FreeLibrary(handle);
1639
1640 if detected_major != 0 && (detected_major != 1 || detected_minor < 20) {
1644 let ver = format!("{}.{}", detected_major, detected_minor);
1645 return Err(format_ort_version_mismatch(&ver, lib_name));
1646 }
1647 }
1648 }
1649
1650 Ok(())
1651}
1652
1653#[cfg(any(target_os = "linux", target_os = "macos"))]
1654unsafe fn loaded_library_path_from_handle(handle: *mut std::ffi::c_void) -> Option<String> {
1655 let symbol_name = std::ffi::CString::new("OrtGetApiBase").ok()?;
1656 let symbol = unsafe { libc::dlsym(handle, symbol_name.as_ptr()) };
1657 if symbol.is_null() {
1658 return None;
1659 }
1660
1661 let mut info = std::mem::MaybeUninit::<libc::Dl_info>::uninit();
1662 if unsafe { libc::dladdr(symbol, info.as_mut_ptr()) } == 0 {
1663 return None;
1664 }
1665
1666 let info = unsafe { info.assume_init() };
1667 if info.dli_fname.is_null() {
1668 return None;
1669 }
1670
1671 Some(
1672 unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }
1673 .to_string_lossy()
1674 .into_owned(),
1675 )
1676}
1677
1678#[cfg(any(target_os = "linux", target_os = "macos"))]
1679fn detect_ort_version_from_resolved_or_requested(
1680 resolved_path: Option<String>,
1681 requested_lib_name: &str,
1682) -> (Option<String>, String) {
1683 if let Some(path) = resolved_path {
1684 if let Some(version) = detect_ort_version_from_path(&path) {
1685 return (Some(version), path);
1686 }
1687 return (detect_ort_version_from_path(requested_lib_name), path);
1688 }
1689
1690 (
1691 detect_ort_version_from_path(requested_lib_name),
1692 requested_lib_name.to_string(),
1693 )
1694}
1695
1696#[cfg(any(target_os = "linux", target_os = "macos"))]
1697fn detect_ort_version_from_loaded_library(
1698 handle: *mut std::ffi::c_void,
1699 requested_lib_name: &str,
1700) -> (Option<String>, String) {
1701 detect_ort_version_from_resolved_or_requested(
1702 unsafe { loaded_library_path_from_handle(handle) },
1703 requested_lib_name,
1704 )
1705}
1706
1707#[cfg(any(target_os = "linux", target_os = "macos"))]
1710fn detect_ort_version_from_path(lib_path: &str) -> Option<String> {
1711 let path = std::path::Path::new(lib_path);
1712
1713 for candidate in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
1715 .into_iter()
1716 .flatten()
1717 {
1718 if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
1719 if let Some(version) = extract_version_from_filename(name) {
1720 return Some(version);
1721 }
1722 }
1723 }
1724
1725 if let Some(parent) = path.parent() {
1727 if let Ok(entries) = std::fs::read_dir(parent) {
1728 for entry in entries.flatten() {
1729 if let Some(name) = entry.file_name().to_str() {
1730 if name.starts_with("libonnxruntime") {
1731 if let Some(version) = extract_version_from_filename(name) {
1732 return Some(version);
1733 }
1734 }
1735 }
1736 }
1737 }
1738 }
1739
1740 None
1741}
1742
1743#[cfg(any(target_os = "linux", target_os = "macos"))]
1745fn extract_version_from_filename(name: &str) -> Option<String> {
1746 let re = regex::Regex::new(r"(\d+\.\d+\.\d+)").ok()?;
1748 re.find(name).map(|m| m.as_str().to_string())
1749}
1750
1751fn suggest_removal_command(lib_path: &str) -> String {
1752 if lib_path.starts_with("/usr/local/lib")
1753 || lib_path == "libonnxruntime.so"
1754 || lib_path == "libonnxruntime.dylib"
1755 {
1756 #[cfg(target_os = "linux")]
1757 return " sudo rm /usr/local/lib/libonnxruntime* && sudo ldconfig".to_string();
1758 #[cfg(target_os = "macos")]
1759 return " sudo rm /usr/local/lib/libonnxruntime*".to_string();
1760 }
1761 format!(" rm '{}'", lib_path)
1762}
1763
1764pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
1770 format!(
1771 "ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \
1772 Solutions:\n\
1773 1. Auto-fix (recommended): run `npx @cortexkit/aft doctor --fix`. \
1774 This downloads AFT-managed ONNX Runtime v1.24 into AFT's storage and \
1775 configures the bridge to load it instead of the system library — no \
1776 changes to '{}'.\n\
1777 2. Remove the old library and restart (AFT auto-downloads the correct version on next start):\n\
1778 {}\n\
1779 3. Or install ONNX Runtime 1.24 system-wide: https://github.com/microsoft/onnxruntime/releases/tag/v1.24.0\n\
1780 4. Run `npx @cortexkit/aft doctor` for full diagnostics.",
1781 version,
1782 lib_name,
1783 lib_name,
1784 suggest_removal_command(lib_name),
1785 )
1786}
1787
1788pub fn is_onnx_runtime_unavailable(message: &str) -> bool {
1789 if message.trim_start().starts_with("ONNX Runtime not found.") {
1790 return true;
1791 }
1792
1793 let message = message.to_ascii_lowercase();
1794 let mentions_onnx_runtime = ["onnx runtime", "onnxruntime", "libonnxruntime"]
1795 .iter()
1796 .any(|pattern| message.contains(pattern));
1797 let mentions_dynamic_load_failure = [
1798 "shared library",
1799 "dynamic library",
1800 "failed to load",
1801 "could not load",
1802 "unable to load",
1803 "dlopen",
1804 "loadlibrary",
1805 "no such file",
1806 "not found",
1807 ]
1808 .iter()
1809 .any(|pattern| message.contains(pattern));
1810
1811 mentions_onnx_runtime && mentions_dynamic_load_failure
1812}
1813
1814pub fn format_embedding_init_error(error: impl Display) -> String {
1815 let message = error.to_string();
1816
1817 if is_onnx_runtime_unavailable(&message) {
1818 return format!("{ONNX_RUNTIME_INSTALL_HINT} Original error: {message}");
1819 }
1820
1821 format!("failed to initialize semantic embedding model: {message}")
1822}
1823
1824#[derive(Debug, Clone)]
1826pub struct SemanticChunk {
1827 pub file: PathBuf,
1829 pub name: String,
1831 pub qualified_name: Option<String>,
1833 pub kind: SymbolKind,
1835 pub start_line: u32,
1837 pub end_line: u32,
1838 pub exported: bool,
1840 pub embed_text: String,
1842 pub snippet: String,
1844}
1845
1846#[derive(Debug, Clone)]
1848pub struct EmbeddingEntry {
1849 chunk: SemanticChunk,
1850 vector: Vec<f32>,
1851 norm: f32,
1855}
1856
1857impl EmbeddingEntry {
1858 fn new(chunk: SemanticChunk, vector: Vec<f32>) -> Self {
1859 let norm = vector_norm(&vector);
1860 Self {
1861 chunk,
1862 vector,
1863 norm,
1864 }
1865 }
1866}
1867
1868#[derive(Debug)]
1869struct SharedSemanticBase {
1870 entries: Vec<EmbeddingEntry>,
1871 file_mtimes: HashMap<PathBuf, SystemTime>,
1872 file_sizes: HashMap<PathBuf, u64>,
1873 any_missing_sizes: bool,
1874 file_hashes: HashMap<PathBuf, blake3::Hash>,
1875 dimension: usize,
1876 fingerprint: Option<SemanticIndexFingerprint>,
1877 deferred_files: HashSet<PathBuf>,
1878}
1879
1880#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1881struct SharedSemanticBaseKey {
1882 artifact_cache_key: String,
1883 fingerprint: String,
1884 artifact_content_hash: blake3::Hash,
1885}
1886
1887type SharedSemanticBaseRegistry = HashMap<SharedSemanticBaseKey, Weak<SharedSemanticBase>>;
1888
1889fn shared_semantic_bases() -> &'static Mutex<SharedSemanticBaseRegistry> {
1890 static REGISTRY: OnceLock<Mutex<SharedSemanticBaseRegistry>> = OnceLock::new();
1891 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1892}
1893
1894static SHARED_SEMANTIC_BASE_LOADS: AtomicUsize = AtomicUsize::new(0);
1895static SHARED_SEMANTIC_BASE_HITS: AtomicUsize = AtomicUsize::new(0);
1896
1897impl SharedSemanticBase {
1898 fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1899 let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1900 bytes.saturating_add(
1901 crate::memory::usize_to_u64(entry.vector.len())
1902 .saturating_mul(std::mem::size_of::<f32>() as u64),
1903 )
1904 });
1905 let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1906 bytes
1907 .saturating_add(crate::memory::path_bytes(&entry.chunk.file))
1908 .saturating_add(crate::memory::usize_to_u64(entry.chunk.name.len()))
1909 .saturating_add(
1910 entry
1911 .chunk
1912 .qualified_name
1913 .as_ref()
1914 .map(|name| crate::memory::usize_to_u64(name.len()))
1915 .unwrap_or(0),
1916 )
1917 .saturating_add(crate::memory::usize_to_u64(entry.chunk.embed_text.len()))
1918 .saturating_add(crate::memory::usize_to_u64(entry.chunk.snippet.len()))
1919 });
1920 let metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
1921 .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64)
1922 .saturating_add(
1923 self.file_mtimes
1924 .keys()
1925 .chain(self.file_sizes.keys())
1926 .chain(self.file_hashes.keys())
1927 .chain(self.deferred_files.iter())
1928 .map(|path| crate::memory::path_bytes(path))
1929 .fold(0u64, u64::saturating_add),
1930 )
1931 .saturating_add(
1932 crate::memory::usize_to_u64(self.file_mtimes.len())
1933 .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
1934 )
1935 .saturating_add(
1936 crate::memory::usize_to_u64(self.file_sizes.len())
1937 .saturating_mul(std::mem::size_of::<u64>() as u64),
1938 )
1939 .saturating_add(
1940 crate::memory::usize_to_u64(self.file_hashes.len())
1941 .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
1942 );
1943 crate::memory::MemoryEstimate::estimated(
1944 vector_bytes
1945 .saturating_add(text_bytes)
1946 .saturating_add(metadata_bytes),
1947 )
1948 .count("entries", self.entries.len())
1949 .count("indexed_files", self.file_mtimes.len())
1950 .count_u64("vector_bytes", vector_bytes)
1951 .count_u64("text_bytes", text_bytes)
1952 .count_u64("metadata_bytes", metadata_bytes)
1953 }
1954}
1955
1956pub(crate) fn shared_semantic_bases_memory() -> crate::memory::MemoryEstimate {
1957 let mut registry = shared_semantic_bases()
1958 .lock()
1959 .unwrap_or_else(std::sync::PoisonError::into_inner);
1960 registry.retain(|_, base| base.strong_count() > 0);
1961 let bases = registry
1962 .values()
1963 .filter_map(Weak::upgrade)
1964 .collect::<Vec<_>>();
1965 let estimates = bases
1966 .iter()
1967 .map(|base| base.estimated_memory())
1968 .collect::<Vec<_>>();
1969 let bytes = estimates.iter().fold(0u64, |sum, estimate| {
1970 sum.saturating_add(estimate.estimated_bytes.unwrap_or(0))
1971 });
1972 let count_bytes = |name: &str| {
1973 estimates.iter().fold(0u64, |sum, estimate| {
1974 sum.saturating_add(estimate.counts.get(name).copied().unwrap_or(0))
1975 })
1976 };
1977 crate::memory::MemoryEstimate::estimated(bytes)
1978 .count("bases", bases.len())
1979 .count("entries", bases.iter().map(|base| base.entries.len()).sum())
1980 .count_u64("vector_bytes", count_bytes("vector_bytes"))
1981 .count_u64("text_bytes", count_bytes("text_bytes"))
1982 .count_u64("metadata_bytes", count_bytes("metadata_bytes"))
1983 .count_u64(
1984 "loads",
1985 SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) as u64,
1986 )
1987 .count_u64(
1988 "hits",
1989 SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) as u64,
1990 )
1991}
1992
1993fn borrowed_artifact_identity(data_path: &Path) -> Result<(String, blake3::Hash), String> {
1994 let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
1995 let mut hasher = blake3::Hasher::new();
1996 hasher
1997 .update_reader(&mut file)
1998 .map_err(|error| error.to_string())?;
1999 let artifact_content_hash = hasher.finalize();
2000
2001 let mut header = BufReader::new(fs::File::open(data_path).map_err(|error| error.to_string())?);
2002 let mut fixed = [0u8; HEADER_BYTES_V2];
2003 header
2004 .read_exact(&mut fixed)
2005 .map_err(|error| error.to_string())?;
2006 if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
2007 return Err(format!(
2008 "unsupported semantic artifact version {}",
2009 fixed[0]
2010 ));
2011 }
2012 let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
2013 if fingerprint_len == 0 || fingerprint_len > 64 * 1024 {
2014 return Err("semantic artifact fingerprint is missing or oversized".to_string());
2015 }
2016 let mut fingerprint = vec![0u8; fingerprint_len];
2017 header
2018 .read_exact(&mut fingerprint)
2019 .map_err(|error| error.to_string())?;
2020 let fingerprint = String::from_utf8(fingerprint).map_err(|error| error.to_string())?;
2021 Ok((fingerprint, artifact_content_hash))
2022}
2023
2024#[derive(Debug, Clone)]
2027pub struct SemanticIndex {
2028 entries: Vec<EmbeddingEntry>,
2029 file_mtimes: HashMap<PathBuf, SystemTime>,
2031 file_sizes: HashMap<PathBuf, u64>,
2033 any_missing_sizes: bool,
2035 file_hashes: HashMap<PathBuf, blake3::Hash>,
2036 dimension: usize,
2038 fingerprint: Option<SemanticIndexFingerprint>,
2039 project_root: PathBuf,
2040 deferred_files: HashSet<PathBuf>,
2041 shared_base: Option<Arc<SharedSemanticBase>>,
2042 #[cfg(test)]
2043 removal_retain_passes: usize,
2044}
2045
2046#[derive(Debug, Clone, Copy)]
2047struct IndexedFileMetadata {
2048 mtime: SystemTime,
2049 size: u64,
2050 content_hash: blake3::Hash,
2051}
2052
2053#[derive(Debug, Default, Clone, Copy)]
2054struct SemanticCollectPhaseTimings {
2055 sched: Duration,
2056 read_hash: Duration,
2057 parse: Duration,
2058 extract: Duration,
2059 build: Duration,
2060}
2061
2062impl SemanticCollectPhaseTimings {
2063 fn add_assign(&mut self, other: Self) {
2064 self.sched += other.sched;
2065 self.read_hash += other.read_hash;
2066 self.parse += other.parse;
2067 self.extract += other.extract;
2068 self.build += other.build;
2069 }
2070}
2071
2072type CollectedSemanticFile = (
2073 PathBuf,
2074 Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
2075 SemanticCollectPhaseTimings,
2076);
2077
2078#[derive(Debug, Default, Clone, Copy)]
2081pub struct RefreshSummary {
2082 pub changed: usize,
2083 pub added: usize,
2084 pub deleted: usize,
2085 pub total_processed: usize,
2086}
2087
2088impl RefreshSummary {
2089 pub fn is_noop(&self) -> bool {
2091 self.changed == 0 && self.added == 0 && self.deleted == 0
2092 }
2093}
2094
2095#[derive(Debug, Default)]
2096pub struct InvalidatedFilesRefresh {
2097 pub added_entries: Vec<EmbeddingEntry>,
2101 pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
2102 pub completed_paths: Vec<PathBuf>,
2103 pub summary: RefreshSummary,
2104}
2105
2106#[derive(Debug, Clone)]
2107struct ReusableEmbedding {
2108 embed_text: String,
2109 vector: Vec<f32>,
2110}
2111
2112type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
2113
2114#[derive(Debug, Clone)]
2116pub struct SemanticResult {
2117 pub file: PathBuf,
2118 pub name: String,
2119 pub qualified_name: Option<String>,
2120 pub kind: SymbolKind,
2121 pub start_line: u32,
2122 pub end_line: u32,
2123 pub exported: bool,
2124 pub snippet: String,
2125 pub score: f32,
2126 pub rank_score: f32,
2127 pub cap_protected: bool,
2128 pub source: &'static str,
2129}
2130
2131fn relativize_semantic_map<T>(
2132 project_root: &Path,
2133 map: HashMap<PathBuf, T>,
2134) -> Option<HashMap<PathBuf, T>> {
2135 map.into_iter()
2136 .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
2137 .collect()
2138}
2139
2140impl SemanticIndex {
2141 fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
2142 debug_assert!(project_root.is_absolute());
2143 Self {
2144 entries: Vec::new(),
2145 file_mtimes: HashMap::new(),
2146 file_sizes: HashMap::new(),
2147 any_missing_sizes: false,
2148 file_hashes: HashMap::new(),
2149 dimension: shared_base.dimension,
2150 fingerprint: shared_base.fingerprint.clone(),
2151 project_root,
2152 deferred_files: HashSet::new(),
2153 shared_base: Some(shared_base),
2154 #[cfg(test)]
2155 removal_retain_passes: 0,
2156 }
2157 }
2158
2159 pub(crate) fn adopt_frozen_base_for_root(
2160 &mut self,
2161 project_root: &Path,
2162 config: &SemanticBackendConfig,
2163 ) -> Option<Self> {
2164 let expected = SemanticIndexFingerprint::for_config_dimension(config, self.dimension());
2165 if !self
2166 .fingerprint()
2167 .is_some_and(|fingerprint| fingerprint.matches(&expected))
2168 {
2169 return None;
2170 }
2171
2172 if let Some(base) = self.shared_base.as_ref() {
2173 return Some(Self::from_shared_base(
2174 project_root.to_path_buf(),
2175 Arc::clone(base),
2176 ));
2177 }
2178
2179 let paths_are_shareable = self
2180 .entries
2181 .iter()
2182 .all(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
2183 && self
2184 .file_mtimes
2185 .keys()
2186 .chain(self.file_sizes.keys())
2187 .chain(self.file_hashes.keys())
2188 .chain(self.deferred_files.iter())
2189 .all(|path| cache_relative_path(&self.project_root, path).is_some());
2190 if !paths_are_shareable {
2191 return None;
2192 }
2193
2194 let owner_root = self.project_root.clone();
2198 let placeholder = Self::new(owner_root.clone(), self.dimension());
2199 let private = std::mem::replace(self, placeholder);
2200 let base = Arc::new(
2201 private
2202 .into_shared_base()
2203 .expect("shareable semantic paths were validated before freezing"),
2204 );
2205 *self = Self::from_shared_base(owner_root, Arc::clone(&base));
2206 Some(Self::from_shared_base(project_root.to_path_buf(), base))
2207 }
2208
2209 fn into_shared_base(mut self) -> Option<SharedSemanticBase> {
2210 for entry in &mut self.entries {
2211 entry.chunk.file = cache_relative_path(&self.project_root, &entry.chunk.file)?;
2212 }
2213 let deferred_files = self
2214 .deferred_files
2215 .into_iter()
2216 .map(|path| cache_relative_path(&self.project_root, &path))
2217 .collect::<Option<HashSet<_>>>()?;
2218 Some(SharedSemanticBase {
2219 entries: self.entries,
2220 file_mtimes: relativize_semantic_map(&self.project_root, self.file_mtimes)?,
2221 file_sizes: relativize_semantic_map(&self.project_root, self.file_sizes)?,
2222 any_missing_sizes: self.any_missing_sizes,
2223 file_hashes: relativize_semantic_map(&self.project_root, self.file_hashes)?,
2224 dimension: self.dimension,
2225 fingerprint: self.fingerprint,
2226 deferred_files,
2227 })
2228 }
2229
2230 fn materialize_shared_base(&mut self) {
2231 let Some(base) = self.shared_base.take() else {
2232 return;
2233 };
2234 self.entries = base
2235 .entries
2236 .iter()
2237 .cloned()
2238 .map(|mut entry| {
2239 entry.chunk.file = self.project_root.join(&entry.chunk.file);
2240 entry
2241 })
2242 .collect();
2243 self.file_mtimes = base
2244 .file_mtimes
2245 .iter()
2246 .map(|(path, value)| (self.project_root.join(path), *value))
2247 .collect();
2248 self.file_sizes = base
2249 .file_sizes
2250 .iter()
2251 .map(|(path, value)| (self.project_root.join(path), *value))
2252 .collect();
2253 self.any_missing_sizes = base.any_missing_sizes;
2254 self.file_hashes = base
2255 .file_hashes
2256 .iter()
2257 .map(|(path, value)| (self.project_root.join(path), *value))
2258 .collect();
2259 self.dimension = base.dimension;
2260 self.fingerprint = base.fingerprint.clone();
2261 self.deferred_files = base
2262 .deferred_files
2263 .iter()
2264 .map(|path| self.project_root.join(path))
2265 .collect();
2266 }
2267
2268 pub fn new(project_root: PathBuf, dimension: usize) -> Self {
2269 debug_assert!(project_root.is_absolute());
2270 Self {
2271 entries: Vec::new(),
2272 file_mtimes: HashMap::new(),
2273 file_sizes: HashMap::new(),
2274 any_missing_sizes: false,
2275 file_hashes: HashMap::new(),
2276 dimension,
2277 fingerprint: None,
2278 project_root,
2279 deferred_files: HashSet::new(),
2280 shared_base: None,
2281 #[cfg(test)]
2282 removal_retain_passes: 0,
2283 }
2284 }
2285
2286 pub fn entry_count(&self) -> usize {
2288 self.shared_base
2289 .as_ref()
2290 .map(|base| base.entries.len())
2291 .unwrap_or_else(|| self.entries.len())
2292 }
2293
2294 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2298 if let Some(base) = &self.shared_base {
2299 return crate::memory::MemoryEstimate::estimated(0)
2300 .count("entries", base.entries.len())
2301 .count("dimensions", base.dimension)
2302 .count("indexed_files", base.file_mtimes.len())
2303 .count("shared_base_entries", base.entries.len())
2304 .count("overlay_entries", 0)
2305 .count_u64("vector_bytes", 0)
2306 .count_u64("text_bytes", 0)
2307 .count_u64("metadata_bytes", 0);
2308 }
2309 if self.entries.is_empty()
2310 && self.file_mtimes.is_empty()
2311 && self.file_sizes.is_empty()
2312 && self.file_hashes.is_empty()
2313 && self.deferred_files.is_empty()
2314 {
2315 return crate::memory::MemoryEstimate::estimated(0)
2316 .count("entries", 0)
2317 .count("dimensions", self.dimension)
2318 .count("indexed_files", 0)
2319 .count_u64("vector_bytes", 0)
2320 .count_u64("text_bytes", 0)
2321 .count_u64("metadata_bytes", 0)
2322 .count_u64("average_text_bytes", 0)
2323 .count_u64("average_metadata_bytes", 0);
2324 }
2325 let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2326 bytes.saturating_add(
2327 crate::memory::usize_to_u64(entry.vector.len())
2328 .saturating_mul(std::mem::size_of::<f32>() as u64),
2329 )
2330 });
2331 let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2332 let chunk = &entry.chunk;
2333 bytes
2334 .saturating_add(crate::memory::path_bytes(&chunk.file))
2335 .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
2336 .saturating_add(
2337 chunk
2338 .qualified_name
2339 .as_ref()
2340 .map(|name| crate::memory::usize_to_u64(name.len()))
2341 .unwrap_or(0),
2342 )
2343 .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
2344 .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
2345 });
2346 let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
2347 .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
2348 let file_metadata_bytes = self
2349 .file_mtimes
2350 .keys()
2351 .chain(self.file_sizes.keys())
2352 .chain(self.file_hashes.keys())
2353 .chain(self.deferred_files.iter())
2354 .map(|path| crate::memory::path_bytes(path))
2355 .fold(0u64, u64::saturating_add)
2356 .saturating_add(
2357 crate::memory::usize_to_u64(self.file_mtimes.len())
2358 .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
2359 )
2360 .saturating_add(
2361 crate::memory::usize_to_u64(self.file_sizes.len())
2362 .saturating_mul(std::mem::size_of::<u64>() as u64),
2363 )
2364 .saturating_add(
2365 crate::memory::usize_to_u64(self.file_hashes.len())
2366 .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
2367 );
2368 let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
2369 self.fingerprint
2370 .as_ref()
2371 .map(|fingerprint| {
2372 crate::memory::usize_to_u64(fingerprint.backend.len())
2373 .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
2374 .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
2375 })
2376 .unwrap_or(0),
2377 );
2378 let metadata_bytes = entry_metadata_bytes
2379 .saturating_add(file_metadata_bytes)
2380 .saturating_add(index_metadata_bytes);
2381 let entry_count = crate::memory::usize_to_u64(self.entries.len());
2382 crate::memory::MemoryEstimate::estimated(
2383 vector_bytes
2384 .saturating_add(text_bytes)
2385 .saturating_add(metadata_bytes),
2386 )
2387 .count("entries", self.entries.len())
2388 .count("dimensions", self.dimension)
2389 .count("indexed_files", self.file_mtimes.len())
2390 .count_u64("vector_bytes", vector_bytes)
2391 .count_u64("text_bytes", text_bytes)
2392 .count_u64("metadata_bytes", metadata_bytes)
2393 .count_u64(
2394 "average_text_bytes",
2395 text_bytes.checked_div(entry_count).unwrap_or(0),
2396 )
2397 .count_u64(
2398 "average_metadata_bytes",
2399 metadata_bytes.checked_div(entry_count).unwrap_or(0),
2400 )
2401 }
2402
2403 pub fn indexed_file_count(&self) -> usize {
2405 self.shared_base
2406 .as_ref()
2407 .map(|base| base.file_mtimes.len())
2408 .unwrap_or_else(|| self.file_mtimes.len())
2409 }
2410
2411 pub fn status_label(&self) -> &'static str {
2413 if self.entry_count() == 0 {
2414 "empty"
2415 } else {
2416 "ready"
2417 }
2418 }
2419
2420 fn collect_chunks(
2421 project_root: &Path,
2422 files: &[PathBuf],
2423 ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
2424 let collect_started = Instant::now();
2425 let collect_one = |file: &Path, sched: Duration| {
2426 let mut phases = SemanticCollectPhaseTimings {
2427 sched,
2428 ..SemanticCollectPhaseTimings::default()
2429 };
2430 let result = collect_semantic_file(project_root, file, &mut phases);
2431 (file.to_path_buf(), result, phases)
2432 };
2433 let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
2434 files
2435 .iter()
2436 .map(|file| collect_one(file, Duration::ZERO))
2437 .collect()
2438 } else {
2439 files
2440 .par_iter()
2441 .map(|file| collect_one(file, collect_started.elapsed()))
2442 .collect()
2443 };
2444
2445 let mut chunks: Vec<SemanticChunk> = Vec::new();
2446 let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
2447 let mut phases = SemanticCollectPhaseTimings::default();
2448
2449 for (file, result, file_phases) in per_file {
2450 phases.add_assign(file_phases);
2451 match result {
2452 Ok((metadata, file_chunks)) => {
2453 file_metadata.insert(file, metadata);
2454 chunks.extend(file_chunks);
2455 }
2456 Err(error) => {
2457 if error == "unsupported file extension" {
2463 continue;
2464 }
2465 slog_warn!(
2466 "failed to collect semantic chunks for {}: {}",
2467 file.display(),
2468 error
2469 );
2470 }
2471 }
2472 }
2473
2474 let collect_ms = collect_started
2475 .elapsed()
2476 .as_millis()
2477 .min(u128::from(u64::MAX)) as u64;
2478 crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
2479 slog_info!(
2480 "semantic collect: {} chunks from {} files in {} ms",
2481 chunks.len(),
2482 file_metadata.len(),
2483 collect_ms
2484 );
2485 if collect_ms > 50 {
2486 slog_info!(
2487 "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
2488 phases.sched.as_millis(),
2489 phases.read_hash.as_millis(),
2490 phases.parse.as_millis(),
2491 phases.extract.as_millis(),
2492 phases.build.as_millis(),
2493 );
2494 }
2495
2496 (chunks, file_metadata)
2497 }
2498
2499 fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
2500 let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2501 let mut reuse_map: ChunkReuseMap = HashMap::new();
2502
2503 for entry in &self.entries {
2504 if !requested.contains(entry.chunk.file.as_path()) {
2505 continue;
2506 }
2507
2508 let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
2513 reuse_map
2514 .entry(entry.chunk.file.clone())
2515 .or_default()
2516 .entry(hash)
2517 .or_default()
2518 .push(ReusableEmbedding {
2519 embed_text: entry.chunk.embed_text.clone(),
2520 vector: entry.vector.clone(),
2521 });
2522 }
2523
2524 reuse_map
2525 }
2526
2527 fn reusable_vector_for_chunk(
2528 reuse_map: &ChunkReuseMap,
2529 chunk: &SemanticChunk,
2530 ) -> Option<Vec<f32>> {
2531 let hash = blake3::hash(chunk.embed_text.as_bytes());
2532 reuse_map
2533 .get(&chunk.file)?
2534 .get(&hash)?
2535 .iter()
2536 .find(|candidate| candidate.embed_text == chunk.embed_text)
2537 .map(|candidate| candidate.vector.clone())
2538 }
2539
2540 fn entries_for_chunks_with_reuse<F, P>(
2541 chunks: Vec<SemanticChunk>,
2542 reuse_map: &ChunkReuseMap,
2543 embed_fn: &mut F,
2544 max_batch_size: usize,
2545 initial_observed_dimension: Option<usize>,
2546 refresh_label: &str,
2547 progress: &mut P,
2548 ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
2549 where
2550 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2551 P: FnMut(usize, usize),
2552 {
2553 let total_chunks = chunks.len();
2554 progress(0, total_chunks);
2555
2556 let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
2557 let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
2558
2559 for (chunk_index, chunk) in chunks.into_iter().enumerate() {
2560 if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
2561 entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2562 } else {
2563 misses.push((chunk_index, chunk));
2564 }
2565 }
2566
2567 let mut completed = total_chunks.saturating_sub(misses.len());
2568 if completed > 0 {
2569 progress(completed, total_chunks);
2570 }
2571
2572 let batch_size = max_batch_size.max(1);
2573 let mut observed_dimension = initial_observed_dimension;
2574
2575 for batch_start in (0..misses.len()).step_by(batch_size) {
2576 let batch_end = (batch_start + batch_size).min(misses.len());
2577 let batch_texts: Vec<String> = misses[batch_start..batch_end]
2578 .iter()
2579 .map(|(_, chunk)| chunk.embed_text.clone())
2580 .collect();
2581
2582 let vectors = embed_fn(batch_texts)?;
2583 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2584
2585 if let Some(dim) = vectors.first().map(|vector| vector.len()) {
2586 match observed_dimension {
2587 None => observed_dimension = Some(dim),
2588 Some(expected) if dim != expected => {
2589 return Err(format!(
2590 "embedding dimension changed during {refresh_label}: \
2591 cached index uses {expected}, new vectors use {dim}"
2592 ));
2593 }
2594 _ => {}
2595 }
2596 }
2597
2598 for (i, vector) in vectors.into_iter().enumerate() {
2599 let (chunk_index, chunk) = misses[batch_start + i].clone();
2600 entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2601 }
2602
2603 completed += batch_end - batch_start;
2604 progress(completed, total_chunks);
2605 }
2606
2607 let entries = entries_by_chunk
2608 .into_iter()
2609 .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
2610 .collect();
2611
2612 Ok((entries, observed_dimension))
2613 }
2614
2615 fn build_from_chunks<F, P, C>(
2616 project_root: &Path,
2617 chunks: Vec<SemanticChunk>,
2618 file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
2619 embed_fn: &mut F,
2620 max_batch_size: usize,
2621 mut progress: Option<&mut P>,
2622 should_continue: &mut C,
2623 ) -> Result<Self, String>
2624 where
2625 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2626 P: FnMut(usize, usize),
2627 C: FnMut() -> bool,
2628 {
2629 debug_assert!(project_root.is_absolute());
2630 let total_chunks = chunks.len();
2631
2632 if chunks.is_empty() {
2633 return Ok(Self {
2634 entries: Vec::new(),
2635 file_mtimes: file_metadata
2636 .iter()
2637 .map(|(path, metadata)| (path.clone(), metadata.mtime))
2638 .collect(),
2639 file_sizes: file_metadata
2640 .iter()
2641 .map(|(path, metadata)| (path.clone(), metadata.size))
2642 .collect(),
2643 any_missing_sizes: false,
2644 file_hashes: file_metadata
2645 .into_iter()
2646 .map(|(path, metadata)| (path, metadata.content_hash))
2647 .collect(),
2648 dimension: DEFAULT_DIMENSION,
2649 fingerprint: None,
2650 project_root: project_root.to_path_buf(),
2651 deferred_files: HashSet::new(),
2652 shared_base: None,
2653 #[cfg(test)]
2654 removal_retain_passes: 0,
2655 });
2656 }
2657
2658 let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
2660 let mut expected_dimension: Option<usize> = None;
2661 let batch_size = max_batch_size.max(1);
2662 let embed_started = std::time::Instant::now();
2663 let batch_count = total_chunks.div_ceil(batch_size);
2664 for (batch_index, batch_start) in (0..chunks.len()).step_by(batch_size).enumerate() {
2665 if !should_continue() {
2666 slog_info!(
2667 "semantic embed superseded, stopping after {}/{} batches",
2668 batch_index,
2669 batch_count
2670 );
2671 return Err(format!(
2672 "semantic build superseded after {batch_index}/{batch_count} batches"
2673 ));
2674 }
2675 let batch_end = (batch_start + batch_size).min(chunks.len());
2676 let batch_texts: Vec<String> = chunks[batch_start..batch_end]
2677 .iter()
2678 .map(|c| c.embed_text.clone())
2679 .collect();
2680
2681 let vectors = embed_fn(batch_texts)?;
2682 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2683
2684 if let Some(dim) = vectors.first().map(|v| v.len()) {
2686 match expected_dimension {
2687 None => expected_dimension = Some(dim),
2688 Some(expected) if dim != expected => {
2689 return Err(format!(
2690 "embedding dimension changed across batches: expected {expected}, got {dim}"
2691 ));
2692 }
2693 _ => {}
2694 }
2695 }
2696
2697 for (i, vector) in vectors.into_iter().enumerate() {
2698 let chunk_idx = batch_start + i;
2699 entries.push(EmbeddingEntry::new(chunks[chunk_idx].clone(), vector));
2700 }
2701
2702 if let Some(callback) = progress.as_mut() {
2703 callback(entries.len(), total_chunks);
2704 }
2705 if (batch_index + 1) % 25 == 0 {
2706 slog_info!(
2707 "semantic embed progress: batch {}/{} ({} / {} chunks)",
2708 batch_index + 1,
2709 batch_count,
2710 entries.len(),
2711 total_chunks
2712 );
2713 }
2714 }
2715
2716 let embed_ms = embed_started.elapsed().as_millis();
2717 let rate = (total_chunks as u128 * 1000)
2718 .checked_div(embed_ms)
2719 .unwrap_or(0) as u64;
2720 slog_info!(
2721 "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
2722 total_chunks,
2723 batch_count,
2724 embed_ms,
2725 rate
2726 );
2727
2728 let dimension = entries
2729 .first()
2730 .map(|e| e.vector.len())
2731 .unwrap_or(DEFAULT_DIMENSION);
2732
2733 Ok(Self {
2734 entries,
2735 file_mtimes: file_metadata
2736 .iter()
2737 .map(|(path, metadata)| (path.clone(), metadata.mtime))
2738 .collect(),
2739 file_sizes: file_metadata
2740 .iter()
2741 .map(|(path, metadata)| (path.clone(), metadata.size))
2742 .collect(),
2743 any_missing_sizes: false,
2744 file_hashes: file_metadata
2745 .into_iter()
2746 .map(|(path, metadata)| (path, metadata.content_hash))
2747 .collect(),
2748 dimension,
2749 fingerprint: None,
2750 project_root: project_root.to_path_buf(),
2751 deferred_files: HashSet::new(),
2752 shared_base: None,
2753 #[cfg(test)]
2754 removal_retain_passes: 0,
2755 })
2756 }
2757
2758 pub fn build<F>(
2761 project_root: &Path,
2762 files: &[PathBuf],
2763 embed_fn: &mut F,
2764 max_batch_size: usize,
2765 ) -> Result<Self, String>
2766 where
2767 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2768 {
2769 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2770 let mut should_continue = || true;
2771 Self::build_from_chunks(
2772 project_root,
2773 chunks,
2774 file_mtimes,
2775 embed_fn,
2776 max_batch_size,
2777 Option::<&mut fn(usize, usize)>::None,
2778 &mut should_continue,
2779 )
2780 }
2781
2782 pub fn build_with_progress<F, P>(
2784 project_root: &Path,
2785 files: &[PathBuf],
2786 embed_fn: &mut F,
2787 max_batch_size: usize,
2788 progress: &mut P,
2789 ) -> Result<Self, String>
2790 where
2791 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2792 P: FnMut(usize, usize),
2793 {
2794 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2795 let total_chunks = chunks.len();
2796 progress(0, total_chunks);
2797 let mut should_continue = || true;
2798 Self::build_from_chunks(
2799 project_root,
2800 chunks,
2801 file_mtimes,
2802 embed_fn,
2803 max_batch_size,
2804 Some(progress),
2805 &mut should_continue,
2806 )
2807 }
2808
2809 pub fn build_with_progress_and_cancellation<F, P, C>(
2813 project_root: &Path,
2814 files: &[PathBuf],
2815 embed_fn: &mut F,
2816 max_batch_size: usize,
2817 progress: &mut P,
2818 should_continue: &mut C,
2819 ) -> Result<Self, String>
2820 where
2821 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2822 P: FnMut(usize, usize),
2823 C: FnMut() -> bool,
2824 {
2825 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2826 let total_chunks = chunks.len();
2827 progress(0, total_chunks);
2828 Self::build_from_chunks(
2829 project_root,
2830 chunks,
2831 file_mtimes,
2832 embed_fn,
2833 max_batch_size,
2834 Some(progress),
2835 should_continue,
2836 )
2837 }
2838
2839 pub fn refresh_stale_files<F, P>(
2850 &mut self,
2851 project_root: &Path,
2852 current_files: &[PathBuf],
2853 embed_fn: &mut F,
2854 max_batch_size: usize,
2855 progress: &mut P,
2856 ) -> Result<RefreshSummary, String>
2857 where
2858 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2859 P: FnMut(usize, usize),
2860 {
2861 self.refresh_stale_files_with_strategy(
2862 project_root,
2863 current_files,
2864 embed_fn,
2865 max_batch_size,
2866 progress,
2867 cache_freshness::VerifyStrategy::Strict,
2868 )
2869 }
2870
2871 pub(crate) fn refresh_stale_files_with_strategy<F, P>(
2872 &mut self,
2873 project_root: &Path,
2874 current_files: &[PathBuf],
2875 embed_fn: &mut F,
2876 max_batch_size: usize,
2877 progress: &mut P,
2878 verify_strategy: cache_freshness::VerifyStrategy,
2879 ) -> Result<RefreshSummary, String>
2880 where
2881 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2882 P: FnMut(usize, usize),
2883 {
2884 self.materialize_shared_base();
2885 self.backfill_missing_file_sizes();
2886
2887 let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
2889 self.deferred_files
2890 .retain(|path| current_set.contains(path.as_path()));
2891 let total_processed = current_set.len() + self.file_mtimes.len()
2892 - self
2893 .file_mtimes
2894 .keys()
2895 .filter(|path| current_set.contains(path.as_path()))
2896 .count();
2897
2898 enum IndexedFileCheck {
2901 Deleted(PathBuf),
2902 MissingMetadata(PathBuf),
2903 Verified(PathBuf, FreshnessVerdict),
2904 }
2905
2906 let mut deleted: Vec<PathBuf> = Vec::new();
2907 let mut changed: Vec<PathBuf> = Vec::new();
2908 let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
2909 let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
2910 let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
2911
2912 for indexed_path in indexed_paths {
2913 let check_index = checks.len();
2914 if !current_set.contains(indexed_path.as_path()) {
2915 checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
2916 continue;
2917 }
2918 let cached = match (
2919 self.file_mtimes.get(&indexed_path),
2920 self.file_sizes.get(&indexed_path),
2921 self.file_hashes.get(&indexed_path),
2922 ) {
2923 (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
2924 mtime: *mtime,
2925 size: *size,
2926 content_hash: *hash,
2927 }),
2928 _ => None,
2929 };
2930 if let Some(freshness) = cached {
2931 strict_verify_inputs.push((check_index, indexed_path, freshness));
2932 checks.push(None);
2933 } else {
2934 checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
2935 }
2936 }
2937
2938 let verified = match verify_strategy {
2939 cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
2940 strict_verify_inputs,
2941 cache_freshness::VerifyStrategy::StatFirst,
2942 ),
2943 cache_freshness::VerifyStrategy::Strict => {
2944 cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
2945 }
2946 };
2947 for (check_index, path, verdict) in verified {
2948 checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
2949 }
2950
2951 for check in checks {
2952 match check.expect("freshness check should be populated") {
2953 IndexedFileCheck::Deleted(path) => deleted.push(path),
2954 IndexedFileCheck::MissingMetadata(path) => changed.push(path),
2955 IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
2956 IndexedFileCheck::Verified(
2957 path,
2958 FreshnessVerdict::ContentFresh {
2959 new_mtime,
2960 new_size,
2961 },
2962 ) => {
2963 self.file_mtimes.insert(path.clone(), new_mtime);
2964 self.file_sizes.insert(path, new_size);
2965 }
2966 IndexedFileCheck::Verified(
2967 path,
2968 FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
2969 ) => {
2970 changed.push(path);
2971 }
2972 }
2973 }
2974
2975 let mut added: Vec<PathBuf> = Vec::new();
2977 for path in current_files {
2978 if !self.file_mtimes.contains_key(path) {
2979 added.push(path.clone());
2980 }
2981 }
2982
2983 if deleted.is_empty() && changed.is_empty() && added.is_empty() {
2985 progress(0, 0);
2986 return Ok(RefreshSummary {
2987 total_processed,
2988 ..RefreshSummary::default()
2989 });
2990 }
2991
2992 if !deleted.is_empty() {
2996 self.remove_indexed_files(&deleted);
2997 }
2998
2999 let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
3001 to_embed.extend(changed.iter().cloned());
3002 to_embed.extend(added.iter().cloned());
3003
3004 if to_embed.is_empty() {
3005 progress(0, 0);
3007 return Ok(RefreshSummary {
3008 changed: 0,
3009 added: 0,
3010 deleted: deleted.len(),
3011 total_processed,
3012 });
3013 }
3014
3015 let reuse_map = self.build_chunk_reuse_map(&changed);
3016 let (chunks, fresh_metadata) = Self::collect_chunks(project_root, &to_embed);
3017 let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
3018 let vanished = to_embed
3019 .iter()
3020 .filter(|path| {
3021 changed_set.contains(path.as_path())
3022 && !fresh_metadata.contains_key(*path)
3023 && !path.exists()
3024 })
3025 .cloned()
3026 .collect::<Vec<_>>();
3027 if !vanished.is_empty() {
3028 self.remove_indexed_files(&vanished);
3029 deleted.extend(vanished);
3030 }
3031
3032 if chunks.is_empty() {
3033 progress(0, 0);
3034 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3035 for file in &successful_files {
3036 self.deferred_files.remove(file);
3037 }
3038 if !successful_files.is_empty() {
3039 self.entries
3040 .retain(|entry| !successful_files.contains(&entry.chunk.file));
3041 }
3042 let changed_count = changed
3043 .iter()
3044 .filter(|path| successful_files.contains(*path))
3045 .count();
3046 let added_count = added
3047 .iter()
3048 .filter(|path| successful_files.contains(*path))
3049 .count();
3050 for (file, metadata) in fresh_metadata {
3051 self.file_mtimes.insert(file.clone(), metadata.mtime);
3052 self.file_sizes.insert(file.clone(), metadata.size);
3053 self.file_hashes.insert(file.clone(), metadata.content_hash);
3054 }
3055 return Ok(RefreshSummary {
3056 changed: changed_count,
3057 added: added_count,
3058 deleted: deleted.len(),
3059 total_processed,
3060 });
3061 }
3062
3063 let existing_dimension = if self.entries.is_empty() {
3066 None
3067 } else {
3068 Some(self.dimension)
3069 };
3070 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
3071 chunks,
3072 &reuse_map,
3073 embed_fn,
3074 max_batch_size,
3075 existing_dimension,
3076 "incremental refresh",
3077 progress,
3078 )?;
3079
3080 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3081 for file in &successful_files {
3082 self.deferred_files.remove(file);
3083 }
3084 if !successful_files.is_empty() {
3085 self.entries
3086 .retain(|entry| !successful_files.contains(&entry.chunk.file));
3087 }
3088
3089 self.entries.extend(new_entries);
3090 for (file, metadata) in fresh_metadata {
3091 self.file_mtimes.insert(file.clone(), metadata.mtime);
3092 self.file_sizes.insert(file.clone(), metadata.size);
3093 self.file_hashes.insert(file, metadata.content_hash);
3094 }
3095 if let Some(dim) = observed_dimension {
3096 self.dimension = dim;
3097 }
3098
3099 Ok(RefreshSummary {
3100 changed: changed
3101 .iter()
3102 .filter(|path| successful_files.contains(*path))
3103 .count(),
3104 added: added
3105 .iter()
3106 .filter(|path| successful_files.contains(*path))
3107 .count(),
3108 deleted: deleted.len(),
3109 total_processed,
3110 })
3111 }
3112
3113 pub fn refresh_invalidated_files<F, P>(
3120 &mut self,
3121 project_root: &Path,
3122 paths: &[PathBuf],
3123 embed_fn: &mut F,
3124 max_batch_size: usize,
3125 max_files: usize,
3126 progress: &mut P,
3127 ) -> Result<InvalidatedFilesRefresh, String>
3128 where
3129 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3130 P: FnMut(usize, usize),
3131 {
3132 self.materialize_shared_base();
3133 self.backfill_missing_file_sizes();
3134
3135 self.deferred_files.retain(|path| path.exists());
3136 let mut requested_paths = paths.to_vec();
3137 requested_paths.extend(self.deferred_files.iter().cloned());
3138 requested_paths.sort();
3139 requested_paths.dedup();
3140 let total_processed = requested_paths.len();
3141
3142 if requested_paths.is_empty() {
3143 progress(0, 0);
3144 return Ok(InvalidatedFilesRefresh {
3145 summary: RefreshSummary {
3146 total_processed,
3147 ..RefreshSummary::default()
3148 },
3149 ..InvalidatedFilesRefresh::default()
3150 });
3151 }
3152
3153 let previously_indexed: HashSet<PathBuf> = requested_paths
3154 .iter()
3155 .filter(|path| self.file_mtimes.contains_key(*path))
3156 .cloned()
3157 .collect();
3158 let reuse_map = self.build_chunk_reuse_map(&requested_paths);
3159
3160 self.remove_indexed_files(&requested_paths);
3164
3165 let existing_paths = requested_paths
3166 .iter()
3167 .filter(|path| path.exists())
3168 .cloned()
3169 .collect::<Vec<_>>();
3170 let deleted = requested_paths
3171 .iter()
3172 .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
3173 .count();
3174
3175 if existing_paths.is_empty() {
3176 for path in &requested_paths {
3177 if !path.exists() {
3178 self.deferred_files.remove(path);
3179 }
3180 }
3181 progress(0, 0);
3182 return Ok(InvalidatedFilesRefresh {
3183 completed_paths: requested_paths,
3184 summary: RefreshSummary {
3185 deleted,
3186 total_processed,
3187 ..RefreshSummary::default()
3188 },
3189 ..InvalidatedFilesRefresh::default()
3190 });
3191 }
3192
3193 let (mut chunks, mut fresh_metadata) = Self::collect_chunks(project_root, &existing_paths);
3194
3195 let retained_file_count = self.file_mtimes.len();
3196 let changed_successful_count = existing_paths
3197 .iter()
3198 .filter(|path| {
3199 previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
3200 })
3201 .count();
3202 let available_new_files =
3203 max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
3204 let new_successful_files = existing_paths
3205 .iter()
3206 .filter(|path| {
3207 !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
3208 })
3209 .cloned()
3210 .collect::<Vec<_>>();
3211 if new_successful_files.len() > available_new_files {
3212 let allowed_new_files = new_successful_files
3213 .iter()
3214 .take(available_new_files)
3215 .cloned()
3216 .collect::<HashSet<_>>();
3217 let deferred_new_files = new_successful_files
3218 .into_iter()
3219 .filter(|path| !allowed_new_files.contains(path))
3220 .collect::<HashSet<_>>();
3221
3222 fresh_metadata.retain(|file, _| {
3223 previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
3224 });
3225 chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
3226
3227 if !deferred_new_files.is_empty() {
3228 for path in &deferred_new_files {
3229 self.deferred_files.insert(path.clone());
3230 }
3231 slog_warn!(
3232 "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
3233 deferred_new_files.len(),
3234 max_files
3235 );
3236 }
3237 }
3238
3239 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3240 for file in &successful_files {
3241 self.deferred_files.remove(file);
3242 }
3243 let changed = successful_files
3244 .iter()
3245 .filter(|path| previously_indexed.contains(path.as_path()))
3246 .count();
3247 let added = successful_files.len().saturating_sub(changed);
3248 let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
3249
3250 if chunks.is_empty() {
3251 progress(0, 0);
3252 for (file, metadata) in fresh_metadata {
3253 let freshness = FileFreshness {
3254 mtime: metadata.mtime,
3255 size: metadata.size,
3256 content_hash: metadata.content_hash,
3257 };
3258 self.file_mtimes.insert(file.clone(), freshness.mtime);
3259 self.file_sizes.insert(file.clone(), freshness.size);
3260 self.file_hashes
3261 .insert(file.clone(), freshness.content_hash);
3262 updated_metadata.push((file, freshness));
3263 }
3264
3265 return Ok(InvalidatedFilesRefresh {
3266 updated_metadata,
3267 completed_paths: requested_paths,
3268 summary: RefreshSummary {
3269 changed,
3270 added,
3271 deleted,
3272 total_processed,
3273 },
3274 ..InvalidatedFilesRefresh::default()
3275 });
3276 }
3277
3278 let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
3279 {
3280 None
3281 } else {
3282 Some(self.dimension)
3283 };
3284 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
3285 chunks,
3286 &reuse_map,
3287 embed_fn,
3288 max_batch_size,
3289 initial_observed_dimension,
3290 "invalidated-file refresh",
3291 progress,
3292 )?;
3293
3294 let added_entries = new_entries.clone();
3295 self.entries.extend(new_entries);
3296 for (file, metadata) in fresh_metadata {
3297 let freshness = FileFreshness {
3298 mtime: metadata.mtime,
3299 size: metadata.size,
3300 content_hash: metadata.content_hash,
3301 };
3302 self.file_mtimes.insert(file.clone(), freshness.mtime);
3303 self.file_sizes.insert(file.clone(), freshness.size);
3304 self.file_hashes
3305 .insert(file.clone(), freshness.content_hash);
3306 updated_metadata.push((file, freshness));
3307 }
3308 if let Some(dim) = observed_dimension {
3309 self.dimension = dim;
3310 }
3311
3312 Ok(InvalidatedFilesRefresh {
3313 added_entries,
3314 updated_metadata,
3315 completed_paths: requested_paths,
3316 summary: RefreshSummary {
3317 changed,
3318 added,
3319 deleted,
3320 total_processed,
3321 },
3322 })
3323 }
3324
3325 pub fn apply_refresh_update(
3326 &mut self,
3327 added_entries: Vec<EmbeddingEntry>,
3328 updated_metadata: Vec<(PathBuf, FileFreshness)>,
3329 completed_paths: &[PathBuf],
3330 ) {
3331 self.materialize_shared_base();
3332 self.remove_indexed_files(completed_paths);
3336
3337 let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
3338 self.entries.extend(added_entries);
3339 for (file, freshness) in updated_metadata {
3340 self.file_mtimes.insert(file.clone(), freshness.mtime);
3341 self.file_sizes.insert(file.clone(), freshness.size);
3342 self.file_hashes.insert(file, freshness.content_hash);
3343 }
3344 if let Some(dim) = observed_dimension {
3345 self.dimension = dim;
3346 }
3347 }
3348
3349 fn remove_indexed_file_keys(
3350 &mut self,
3351 entry_files: &HashSet<PathBuf>,
3352 metadata_files: &[PathBuf],
3353 ) {
3354 #[cfg(test)]
3355 {
3356 self.removal_retain_passes += 1;
3357 }
3358 self.entries
3359 .retain(|entry| !entry_files.contains(&entry.chunk.file));
3360 for path in metadata_files {
3361 self.file_mtimes.remove(path);
3362 self.file_sizes.remove(path);
3363 self.file_hashes.remove(path);
3364 }
3365 }
3366
3367 fn remove_indexed_files(&mut self, files: &[PathBuf]) {
3368 let deleted_set = files.iter().cloned().collect();
3369 self.remove_indexed_file_keys(&deleted_set, files);
3370 }
3371
3372 pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
3374 self.search_filtered(query_vector, top_k, |_| true)
3375 }
3376
3377 pub(crate) fn search_filtered<F>(
3382 &self,
3383 query_vector: &[f32],
3384 top_k: usize,
3385 include: F,
3386 ) -> Vec<SemanticResult>
3387 where
3388 F: Fn(&Path) -> bool,
3389 {
3390 let (entries, dimension) = self
3391 .shared_base
3392 .as_ref()
3393 .map(|base| (base.entries.as_slice(), base.dimension))
3394 .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
3395 if entries.is_empty() || query_vector.len() != dimension {
3396 return Vec::new();
3397 }
3398
3399 let query_norm = vector_norm(query_vector);
3402 let cancellation = crate::executor::current_job_cancellation();
3403 let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
3404 for (i, entry) in entries.iter().enumerate() {
3405 if i % 64 == 0
3406 && cancellation
3407 .as_ref()
3408 .is_some_and(|token| token.cancel_requested_before_commit())
3409 {
3410 break;
3411 }
3412 let included = if self.shared_base.is_some() {
3413 include(&self.project_root.join(&entry.chunk.file))
3414 } else {
3415 include(&entry.chunk.file)
3416 };
3417 if !included {
3418 continue;
3419 }
3420
3421 let dot = if query_vector.len() == entry.vector.len() {
3422 dot_product(query_vector, &entry.vector)
3423 } else {
3424 0.0
3425 };
3426 let denom = query_norm * entry.norm;
3427 let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
3428 if entry.chunk.exported {
3429 score *= 1.1;
3430 }
3431 scored.push((score, i));
3432 }
3433
3434 let keep = top_k.min(scored.len());
3435 if keep == 0 {
3436 return Vec::new();
3437 }
3438
3439 if keep < scored.len() {
3440 scored.select_nth_unstable_by(keep, semantic_score_order);
3441 scored.truncate(keep);
3442 }
3443 scored.sort_by(semantic_score_order);
3444
3445 scored
3446 .into_iter()
3447 .map(|(score, idx)| {
3451 let entry = &entries[idx];
3452 SemanticResult {
3453 file: if self.shared_base.is_some() {
3454 self.project_root.join(&entry.chunk.file)
3455 } else {
3456 entry.chunk.file.clone()
3457 },
3458 name: entry.chunk.name.clone(),
3459 qualified_name: entry.chunk.qualified_name.clone(),
3460 kind: entry.chunk.kind.clone(),
3461 start_line: entry.chunk.start_line,
3462 end_line: entry.chunk.end_line,
3463 exported: entry.chunk.exported,
3464 snippet: entry.chunk.snippet.clone(),
3465 score,
3466 rank_score: score,
3467 cap_protected: false,
3468 source: "semantic",
3469 }
3470 })
3471 .collect()
3472 }
3473
3474 pub fn len(&self) -> usize {
3476 self.entry_count()
3477 }
3478
3479 pub fn is_file_stale(&self, file: &Path) -> bool {
3481 let relative;
3482 let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
3483 relative = file
3484 .strip_prefix(&self.project_root)
3485 .unwrap_or(file)
3486 .to_path_buf();
3487 (
3488 &base.file_mtimes,
3489 &base.file_sizes,
3490 &base.file_hashes,
3491 relative.as_path(),
3492 )
3493 } else {
3494 (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
3495 };
3496 let Some(stored_mtime) = file_mtimes.get(lookup) else {
3497 return true;
3498 };
3499 let Some(stored_size) = file_sizes.get(lookup) else {
3500 return true;
3501 };
3502 let Some(stored_hash) = file_hashes.get(lookup) else {
3503 return true;
3504 };
3505 let cached = FileFreshness {
3506 mtime: *stored_mtime,
3507 size: *stored_size,
3508 content_hash: *stored_hash,
3509 };
3510 match cache_freshness::verify_file_strict(file, &cached) {
3511 FreshnessVerdict::HotFresh => false,
3512 FreshnessVerdict::ContentFresh { .. } => false,
3513 FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
3514 }
3515 }
3516
3517 fn backfill_missing_file_sizes(&mut self) {
3518 if !self.any_missing_sizes {
3519 return;
3520 }
3521
3522 for path in self.file_mtimes.keys() {
3523 if self.file_sizes.contains_key(path) {
3524 continue;
3525 }
3526 if let Ok(metadata) = fs::metadata(path) {
3527 self.file_sizes.insert(path.clone(), metadata.len());
3528 if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
3529 self.file_hashes.insert(path.clone(), hash);
3530 }
3531 }
3532 }
3533 self.any_missing_sizes = self
3534 .file_mtimes
3535 .keys()
3536 .any(|path| !self.file_sizes.contains_key(path));
3537 }
3538
3539 pub fn remove_file(&mut self, file: &Path) {
3541 self.invalidate_file(file);
3542 }
3543
3544 pub fn invalidate_file(&mut self, file: &Path) {
3545 let file = file.to_path_buf();
3546 self.invalidate_files(std::slice::from_ref(&file));
3547 }
3548
3549 pub fn invalidate_files(&mut self, files: &[PathBuf]) {
3550 if files.is_empty() {
3551 return;
3552 }
3553 self.materialize_shared_base();
3554
3555 let mut invalidated = HashSet::with_capacity(files.len().saturating_mul(2));
3558 let mut metadata_keys = Vec::with_capacity(files.len().saturating_mul(2));
3559 for file in files {
3560 metadata_keys.push(file.clone());
3561 invalidated.insert(file.clone());
3562 let canonical = canonicalize_existing_or_deleted_path(file);
3563 if canonical != *file {
3564 metadata_keys.push(canonical.clone());
3565 invalidated.insert(canonical);
3566 }
3567 }
3568 self.remove_indexed_file_keys(&invalidated, &metadata_keys);
3569 }
3570
3571 #[cfg(test)]
3572 pub(crate) fn removal_retain_passes_for_test(&self) -> usize {
3573 self.removal_retain_passes
3574 }
3575
3576 #[cfg(test)]
3577 pub(crate) fn uses_shared_base_for_test(&self) -> bool {
3578 self.shared_base.is_some()
3579 }
3580
3581 pub fn dimension(&self) -> usize {
3583 self.shared_base
3584 .as_ref()
3585 .map(|base| base.dimension)
3586 .unwrap_or(self.dimension)
3587 }
3588
3589 pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
3590 self.shared_base
3591 .as_ref()
3592 .and_then(|base| base.fingerprint.as_ref())
3593 .or(self.fingerprint.as_ref())
3594 }
3595
3596 pub fn backend_label(&self) -> Option<&str> {
3597 self.fingerprint().map(|f| f.backend.as_str())
3598 }
3599
3600 pub fn model_label(&self) -> Option<&str> {
3601 self.fingerprint().map(|f| f.model.as_str())
3602 }
3603
3604 pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
3605 self.materialize_shared_base();
3606 self.fingerprint = Some(fingerprint);
3607 }
3608
3609 pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
3613 if self.shared_base.is_some() {
3614 let mut private = self.clone();
3615 private.materialize_shared_base();
3616 return private.write_to_disk(storage_dir, project_key);
3617 }
3618 let dir = storage_dir.join("semantic").join(project_key);
3619 let data_path = dir.join("semantic.bin");
3620 let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
3621 if !access.allows_write(project_key, &data_path) {
3622 return false;
3623 }
3624 if let Err(e) = fs::create_dir_all(&dir) {
3625 slog_warn!("failed to create semantic cache dir: {}", e);
3626 return false;
3627 }
3628 let tmp_path = dir.join(format!(
3629 "semantic.bin.tmp.{}.{}",
3630 std::process::id(),
3631 SystemTime::now()
3632 .duration_since(SystemTime::UNIX_EPOCH)
3633 .unwrap_or(Duration::ZERO)
3634 .as_nanos()
3635 ));
3636 let write_result = (|| -> io::Result<usize> {
3637 let file = fs::File::create(&tmp_path)?;
3638 let mut writer = BufWriter::new(file);
3639 let bytes_written = self.write_to_writer(&mut writer)?;
3640 writer.flush()?;
3641 writer.get_ref().sync_all()?;
3642 Ok(bytes_written)
3643 })();
3644 let bytes_written = match write_result {
3645 Ok(bytes_written) => bytes_written,
3646 Err(e) => {
3647 slog_warn!("failed to write semantic index: {}", e);
3648 let _ = fs::remove_file(&tmp_path);
3649 return false;
3650 }
3651 };
3652 if let Err(e) = crate::fs_lock::rename_over(&tmp_path, &data_path) {
3653 slog_warn!("failed to rename semantic index: {}", e);
3654 let _ = fs::remove_file(&tmp_path);
3655 return false;
3656 }
3657 slog_info!(
3658 "semantic index persisted: {} entries, {:.1} KB",
3659 self.entries.len(),
3660 bytes_written as f64 / 1024.0
3661 );
3662 true
3663 }
3664
3665 pub fn read_from_disk(
3667 storage_dir: &Path,
3668 project_key: &str,
3669 current_canonical_root: &Path,
3670 is_worktree_bridge: bool,
3671 expected_fingerprint: Option<&str>,
3672 ) -> Option<Self> {
3673 debug_assert!(current_canonical_root.is_absolute());
3674 let data_path = storage_dir
3675 .join("semantic")
3676 .join(project_key)
3677 .join("semantic.bin");
3678 let file = fs::File::open(&data_path).ok()?;
3679 let file_len = usize::try_from(file.metadata().ok()?.len()).ok()?;
3680 if file_len < HEADER_BYTES_V1 {
3681 slog_warn!(
3682 "corrupt semantic index (too small: {} bytes), removing",
3683 file_len
3684 );
3685 if !is_worktree_bridge {
3686 let _ = fs::remove_file(&data_path);
3687 }
3688 return None;
3689 }
3690
3691 let mut reader = BufReader::new(file);
3692 let mut version_buf = [0u8; 1];
3693 reader.read_exact(&mut version_buf).ok()?;
3694 let version = version_buf[0];
3695 if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
3696 slog_info!(
3697 "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
3698 version,
3699 SEMANTIC_INDEX_VERSION_V7
3700 );
3701 return None;
3702 }
3703 match Self::from_reader_after_version(
3704 reader,
3705 version,
3706 current_canonical_root,
3707 Some(file_len),
3708 1,
3709 ) {
3710 Ok(index) => {
3711 if let Some(expected) = expected_fingerprint {
3712 let matches = index
3713 .fingerprint()
3714 .map(|fingerprint| fingerprint.matches_expected(expected))
3715 .unwrap_or(false);
3716 if !matches {
3717 log_fingerprint_mismatch(index.fingerprint(), expected);
3718 return None;
3719 }
3720 }
3721 slog_info!(
3722 "loaded semantic index from disk: {} entries",
3723 index.entries.len()
3724 );
3725 Some(index)
3726 }
3727 Err(e) => {
3728 slog_warn!("corrupt semantic index, rebuilding: {}", e);
3729 if !is_worktree_bridge {
3730 let _ = fs::remove_file(&data_path);
3731 }
3732 None
3733 }
3734 }
3735 }
3736
3737 pub(crate) fn read_from_disk_borrow_tolerant(
3738 storage_dir: &Path,
3739 project_key: &str,
3740 current_canonical_root: &Path,
3741 ) -> Option<Self> {
3742 let data_path = storage_dir
3743 .join("semantic")
3744 .join(project_key)
3745 .join("semantic.bin");
3746 let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
3747 Ok(identity) => identity,
3748 Err(error) => {
3749 slog_warn!(
3750 "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
3751 error
3752 );
3753 return Self::read_from_disk(
3754 storage_dir,
3755 project_key,
3756 current_canonical_root,
3757 true,
3758 None,
3759 );
3760 }
3761 };
3762 let key = SharedSemanticBaseKey {
3763 artifact_cache_key: project_key.to_string(),
3764 fingerprint,
3765 artifact_content_hash,
3766 };
3767
3768 {
3769 let mut registry = shared_semantic_bases()
3770 .lock()
3771 .unwrap_or_else(std::sync::PoisonError::into_inner);
3772 registry.retain(|_, base| base.strong_count() > 0);
3773 if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
3774 SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3775 return Some(Self::from_shared_base(
3776 current_canonical_root.to_path_buf(),
3777 base,
3778 ));
3779 }
3780 if registry.keys().any(|existing| {
3781 existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3782 }) {
3783 slog_warn!(
3784 "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
3785 project_key
3786 );
3787 return Self::read_from_disk(
3788 storage_dir,
3789 project_key,
3790 current_canonical_root,
3791 true,
3792 None,
3793 );
3794 }
3795 }
3796
3797 let private = Self::read_from_disk(
3798 storage_dir,
3799 project_key,
3800 current_canonical_root,
3801 true,
3802 Some(&key.fingerprint),
3803 )?;
3804 let Some(base) = private.clone().into_shared_base() else {
3805 slog_warn!(
3806 "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
3807 project_key
3808 );
3809 return Some(private);
3810 };
3811 let base = Arc::new(base);
3812
3813 let mut registry = shared_semantic_bases()
3814 .lock()
3815 .unwrap_or_else(std::sync::PoisonError::into_inner);
3816 registry.retain(|_, base| base.strong_count() > 0);
3817 if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
3818 SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3819 return Some(Self::from_shared_base(
3820 current_canonical_root.to_path_buf(),
3821 existing,
3822 ));
3823 }
3824 if registry.keys().any(|existing| {
3825 existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3826 }) {
3827 slog_warn!(
3828 "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
3829 project_key
3830 );
3831 return Some(private);
3832 }
3833 registry.insert(key, Arc::downgrade(&base));
3834 SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
3835 Some(Self::from_shared_base(
3836 current_canonical_root.to_path_buf(),
3837 base,
3838 ))
3839 }
3840
3841 pub fn to_bytes(&self) -> Vec<u8> {
3843 if self.shared_base.is_some() {
3844 let mut private = self.clone();
3845 private.materialize_shared_base();
3846 return private.to_bytes();
3847 }
3848 let mut buf = Vec::new();
3849 self.write_to_writer(&mut buf)
3850 .expect("writing semantic index to Vec cannot fail");
3851 buf
3852 }
3853
3854 fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
3855 let mut bytes_written = 0usize;
3856 let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
3857 let encoded = fingerprint.as_string();
3858 if encoded.is_empty() {
3859 None
3860 } else {
3861 Some(encoded)
3862 }
3863 });
3864 let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
3865 let file_mtime_count = self
3866 .file_mtimes
3867 .iter()
3868 .filter(|(path, _)| cache_relative_path(&self.project_root, path).is_some())
3869 .count();
3870 let entry_count = self
3871 .entries
3872 .iter()
3873 .filter(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
3874 .count();
3875
3876 let version = SEMANTIC_INDEX_VERSION_V7;
3891 write_counted(writer, &[version], &mut bytes_written)?;
3892 write_counted(
3893 writer,
3894 &(self.dimension as u32).to_le_bytes(),
3895 &mut bytes_written,
3896 )?;
3897 write_counted(
3898 writer,
3899 &(entry_count as u32).to_le_bytes(),
3900 &mut bytes_written,
3901 )?;
3902 write_counted(
3903 writer,
3904 &(fp_bytes_ref.len() as u32).to_le_bytes(),
3905 &mut bytes_written,
3906 )?;
3907 write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
3908
3909 write_counted(
3912 writer,
3913 &(file_mtime_count as u32).to_le_bytes(),
3914 &mut bytes_written,
3915 )?;
3916 for (path, mtime) in &self.file_mtimes {
3917 let Some(relative) = cache_relative_path(&self.project_root, path) else {
3918 continue;
3919 };
3920 let relative = relative.to_string_lossy();
3921 let path_bytes = relative.as_bytes();
3922 write_counted(
3923 writer,
3924 &(path_bytes.len() as u32).to_le_bytes(),
3925 &mut bytes_written,
3926 )?;
3927 write_counted(writer, path_bytes, &mut bytes_written)?;
3928 let duration = mtime
3929 .duration_since(SystemTime::UNIX_EPOCH)
3930 .unwrap_or_default();
3931 write_counted(
3932 writer,
3933 &duration.as_secs().to_le_bytes(),
3934 &mut bytes_written,
3935 )?;
3936 write_counted(
3937 writer,
3938 &duration.subsec_nanos().to_le_bytes(),
3939 &mut bytes_written,
3940 )?;
3941 let size = self.file_sizes.get(path).copied().unwrap_or_default();
3942 write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
3943 let hash = self
3944 .file_hashes
3945 .get(path)
3946 .copied()
3947 .unwrap_or_else(cache_freshness::zero_hash);
3948 write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
3949 }
3950
3951 for entry in &self.entries {
3953 let Some(relative) = cache_relative_path(&self.project_root, &entry.chunk.file) else {
3954 continue;
3955 };
3956 let c = &entry.chunk;
3957
3958 let relative = relative.to_string_lossy();
3960 let file_bytes = relative.as_bytes();
3961 write_counted(
3962 writer,
3963 &(file_bytes.len() as u32).to_le_bytes(),
3964 &mut bytes_written,
3965 )?;
3966 write_counted(writer, file_bytes, &mut bytes_written)?;
3967
3968 let name_bytes = c.name.as_bytes();
3970 write_counted(
3971 writer,
3972 &(name_bytes.len() as u32).to_le_bytes(),
3973 &mut bytes_written,
3974 )?;
3975 write_counted(writer, name_bytes, &mut bytes_written)?;
3976
3977 let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
3979 write_counted(
3980 writer,
3981 &(qualified_name_bytes.len() as u32).to_le_bytes(),
3982 &mut bytes_written,
3983 )?;
3984 write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
3985
3986 write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
3988
3989 write_counted(
3991 writer,
3992 &(c.start_line as u32).to_le_bytes(),
3993 &mut bytes_written,
3994 )?;
3995 write_counted(
3996 writer,
3997 &(c.end_line as u32).to_le_bytes(),
3998 &mut bytes_written,
3999 )?;
4000 write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
4001
4002 let snippet_bytes = c.snippet.as_bytes();
4004 write_counted(
4005 writer,
4006 &(snippet_bytes.len() as u32).to_le_bytes(),
4007 &mut bytes_written,
4008 )?;
4009 write_counted(writer, snippet_bytes, &mut bytes_written)?;
4010
4011 let embed_bytes = c.embed_text.as_bytes();
4013 write_counted(
4014 writer,
4015 &(embed_bytes.len() as u32).to_le_bytes(),
4016 &mut bytes_written,
4017 )?;
4018 write_counted(writer, embed_bytes, &mut bytes_written)?;
4019
4020 for &val in &entry.vector {
4022 write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
4023 }
4024 }
4025
4026 Ok(bytes_written)
4027 }
4028
4029 pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
4031 debug_assert!(current_canonical_root.is_absolute());
4032 if data.len() < HEADER_BYTES_V1 {
4033 return Err("data too short".to_string());
4034 }
4035
4036 Self::from_reader_after_version(
4037 Cursor::new(&data[1..]),
4038 data[0],
4039 current_canonical_root,
4040 Some(data.len()),
4041 1,
4042 )
4043 }
4044
4045 fn from_reader_after_version<R: Read>(
4046 reader: R,
4047 version: u8,
4048 current_canonical_root: &Path,
4049 total_len: Option<usize>,
4050 bytes_read: usize,
4051 ) -> Result<Self, String> {
4052 debug_assert!(current_canonical_root.is_absolute());
4053 let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
4054
4055 if version != SEMANTIC_INDEX_VERSION_V1
4056 && version != SEMANTIC_INDEX_VERSION_V2
4057 && version != SEMANTIC_INDEX_VERSION_V3
4058 && version != SEMANTIC_INDEX_VERSION_V4
4059 && version != SEMANTIC_INDEX_VERSION_V5
4060 && version != SEMANTIC_INDEX_VERSION_V6
4061 && version != SEMANTIC_INDEX_VERSION_V7
4062 {
4063 return Err(format!("unsupported version: {}", version));
4064 }
4065 if (version == SEMANTIC_INDEX_VERSION_V2
4069 || version == SEMANTIC_INDEX_VERSION_V3
4070 || version == SEMANTIC_INDEX_VERSION_V4
4071 || version == SEMANTIC_INDEX_VERSION_V5
4072 || version == SEMANTIC_INDEX_VERSION_V6
4073 || version == SEMANTIC_INDEX_VERSION_V7)
4074 && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
4075 {
4076 return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
4077 }
4078
4079 let dimension = read_u32_stream(&mut reader)? as usize;
4080 let entry_count = read_u32_stream(&mut reader)? as usize;
4081 validate_embedding_dimension(dimension)?;
4082 if entry_count > MAX_ENTRIES {
4083 return Err(format!("too many semantic index entries: {}", entry_count));
4084 }
4085
4086 let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
4092 || version == SEMANTIC_INDEX_VERSION_V3
4093 || version == SEMANTIC_INDEX_VERSION_V4
4094 || version == SEMANTIC_INDEX_VERSION_V5
4095 || version == SEMANTIC_INDEX_VERSION_V6
4096 || version == SEMANTIC_INDEX_VERSION_V7;
4097 let fingerprint = if has_fingerprint_field {
4098 let fingerprint_len = read_u32_stream(&mut reader)? as usize;
4099 if total_len
4100 .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
4101 {
4102 return Err("unexpected end of data reading fingerprint".to_string());
4103 }
4104 if fingerprint_len == 0 {
4105 None
4106 } else {
4107 let mut raw = vec![0u8; fingerprint_len];
4108 read_exact_stream(
4109 &mut reader,
4110 &mut raw,
4111 "unexpected end of data reading fingerprint",
4112 )?;
4113 let raw = String::from_utf8_lossy(&raw).to_string();
4114 Some(
4115 serde_json::from_str::<SemanticIndexFingerprint>(&raw)
4116 .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
4117 )
4118 }
4119 } else {
4120 None
4121 };
4122
4123 let mtime_count = read_u32_stream(&mut reader)? as usize;
4125 if mtime_count > MAX_ENTRIES {
4126 return Err(format!("too many semantic file mtimes: {}", mtime_count));
4127 }
4128
4129 let vector_bytes = entry_count
4130 .checked_mul(dimension)
4131 .and_then(|count| count.checked_mul(F32_BYTES))
4132 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
4133 if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
4134 return Err("semantic index vectors exceed available data".to_string());
4135 }
4136
4137 let mut file_mtimes = HashMap::with_capacity(mtime_count);
4138 let mut file_sizes = HashMap::with_capacity(mtime_count);
4139 let mut file_hashes = HashMap::with_capacity(mtime_count);
4140 for _ in 0..mtime_count {
4141 let path = read_string_stream(&mut reader, total_len)?;
4142 let secs = read_u64_stream(&mut reader)?;
4143 let nanos = if version == SEMANTIC_INDEX_VERSION_V3
4149 || version == SEMANTIC_INDEX_VERSION_V4
4150 || version == SEMANTIC_INDEX_VERSION_V5
4151 || version == SEMANTIC_INDEX_VERSION_V6
4152 || version == SEMANTIC_INDEX_VERSION_V7
4153 {
4154 read_u32_stream(&mut reader)?
4155 } else {
4156 0
4157 };
4158 let size = if version == SEMANTIC_INDEX_VERSION_V5
4159 || version == SEMANTIC_INDEX_VERSION_V6
4160 || version == SEMANTIC_INDEX_VERSION_V7
4161 {
4162 read_u64_stream(&mut reader)?
4163 } else {
4164 0
4165 };
4166 let content_hash =
4167 if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
4168 let mut hash_bytes = [0u8; 32];
4169 read_exact_stream(
4170 &mut reader,
4171 &mut hash_bytes,
4172 "unexpected end of data reading content hash",
4173 )?;
4174 blake3::Hash::from_bytes(hash_bytes)
4175 } else {
4176 cache_freshness::zero_hash()
4177 };
4178 if nanos >= 1_000_000_000 {
4185 return Err(format!(
4186 "invalid semantic mtime: nanos {} >= 1_000_000_000",
4187 nanos
4188 ));
4189 }
4190 let duration = std::time::Duration::new(secs, nanos);
4191 let mtime = SystemTime::UNIX_EPOCH
4192 .checked_add(duration)
4193 .ok_or_else(|| {
4194 format!(
4195 "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
4196 secs, nanos
4197 )
4198 })?;
4199 let path = if version == SEMANTIC_INDEX_VERSION_V6
4200 || version == SEMANTIC_INDEX_VERSION_V7
4201 {
4202 cached_path_under_root(current_canonical_root, &PathBuf::from(path))
4203 .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
4204 } else {
4205 PathBuf::from(path)
4206 };
4207 file_mtimes.insert(path.clone(), mtime);
4208 file_sizes.insert(path.clone(), size);
4209 file_hashes.insert(path, content_hash);
4210 }
4211
4212 let mut entries = Vec::with_capacity(entry_count);
4214 for _ in 0..entry_count {
4215 let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
4216 let file = if version == SEMANTIC_INDEX_VERSION_V6
4217 || version == SEMANTIC_INDEX_VERSION_V7
4218 {
4219 cached_path_under_root(current_canonical_root, &raw_file)
4220 .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
4221 } else {
4222 raw_file
4223 };
4224 let name = read_string_stream(&mut reader, total_len)?;
4225 let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
4226 let qualified_name = read_string_stream(&mut reader, total_len)?;
4227 if qualified_name.is_empty() {
4228 None
4229 } else {
4230 Some(qualified_name)
4231 }
4232 } else {
4233 None
4234 };
4235
4236 let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
4237
4238 let start_line = read_u32_stream(&mut reader)?;
4239 let end_line = read_u32_stream(&mut reader)?;
4240
4241 let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
4242
4243 let snippet = read_string_stream(&mut reader, total_len)?;
4244 let embed_text = read_string_stream(&mut reader, total_len)?;
4245
4246 let vec_bytes = dimension
4248 .checked_mul(F32_BYTES)
4249 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
4250 if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
4251 return Err("unexpected end of data reading vector".to_string());
4252 }
4253 let mut vector = Vec::with_capacity(dimension);
4254 for _ in 0..dimension {
4255 let mut bytes = [0u8; F32_BYTES];
4256 read_exact_stream(
4257 &mut reader,
4258 &mut bytes,
4259 "unexpected end of data reading vector",
4260 )?;
4261 vector.push(f32::from_le_bytes(bytes));
4262 }
4263
4264 entries.push(EmbeddingEntry::new(
4265 SemanticChunk {
4266 file,
4267 name,
4268 qualified_name,
4269 kind,
4270 start_line,
4271 end_line,
4272 exported,
4273 embed_text,
4274 snippet,
4275 },
4276 vector,
4277 ));
4278 }
4279
4280 if entries.len() != entry_count {
4281 return Err(format!(
4282 "semantic cache entry count drift: header={} decoded={}",
4283 entry_count,
4284 entries.len()
4285 ));
4286 }
4287 for entry in &entries {
4288 if !file_mtimes.contains_key(&entry.chunk.file) {
4289 return Err(format!(
4290 "semantic cache metadata missing for entry file {}",
4291 entry.chunk.file.display()
4292 ));
4293 }
4294 }
4295
4296 let any_missing_sizes = file_mtimes
4297 .keys()
4298 .any(|path| !file_sizes.contains_key(path));
4299 Ok(Self {
4300 entries,
4301 file_mtimes,
4302 file_sizes,
4303 any_missing_sizes,
4304 file_hashes,
4305 dimension,
4306 fingerprint,
4307 project_root: current_canonical_root.to_path_buf(),
4308 deferred_files: HashSet::new(),
4309 shared_base: None,
4310 #[cfg(test)]
4311 removal_retain_passes: 0,
4312 })
4313 }
4314}
4315
4316fn write_counted<W: Write>(
4317 writer: &mut W,
4318 bytes: &[u8],
4319 bytes_written: &mut usize,
4320) -> io::Result<()> {
4321 writer.write_all(bytes)?;
4322 *bytes_written = bytes_written.saturating_add(bytes.len());
4323 Ok(())
4324}
4325
4326struct CountingReader<R> {
4327 inner: R,
4328 bytes_read: usize,
4329}
4330
4331impl<R> CountingReader<R> {
4332 fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
4333 Self { inner, bytes_read }
4334 }
4335
4336 fn bytes_read(&self) -> usize {
4337 self.bytes_read
4338 }
4339}
4340
4341impl<R: Read> Read for CountingReader<R> {
4342 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
4343 let read = self.inner.read(buf)?;
4344 self.bytes_read = self.bytes_read.saturating_add(read);
4345 Ok(read)
4346 }
4347}
4348
4349fn read_exact_stream<R: Read>(
4350 reader: &mut CountingReader<R>,
4351 buf: &mut [u8],
4352 eof_message: &'static str,
4353) -> Result<(), String> {
4354 reader.read_exact(buf).map_err(|error| {
4355 if error.kind() == io::ErrorKind::UnexpectedEof {
4356 eof_message.to_string()
4357 } else {
4358 format!("{eof_message}: {error}")
4359 }
4360 })
4361}
4362
4363fn read_u8_stream<R: Read>(
4364 reader: &mut CountingReader<R>,
4365 eof_message: &'static str,
4366) -> Result<u8, String> {
4367 let mut bytes = [0u8; 1];
4368 read_exact_stream(reader, &mut bytes, eof_message)?;
4369 Ok(bytes[0])
4370}
4371
4372fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
4373 let mut bytes = [0u8; 4];
4374 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
4375 Ok(u32::from_le_bytes(bytes))
4376}
4377
4378fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
4379 let mut bytes = [0u8; 8];
4380 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
4381 Ok(u64::from_le_bytes(bytes))
4382}
4383
4384fn read_string_stream<R: Read>(
4385 reader: &mut CountingReader<R>,
4386 total_len: Option<usize>,
4387) -> Result<String, String> {
4388 let len = read_u32_stream(reader)? as usize;
4389 if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
4390 return Err("unexpected end of data reading string".to_string());
4391 }
4392 let mut bytes = vec![0u8; len];
4393 read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
4394 Ok(String::from_utf8_lossy(&bytes).to_string())
4395}
4396
4397struct SourceLineCache<'a> {
4398 lines: Vec<&'a str>,
4399 line_starts: Vec<usize>,
4400}
4401
4402impl<'a> SourceLineCache<'a> {
4403 fn new(source: &'a str) -> Self {
4404 let lines: Vec<&'a str> = source.lines().collect();
4405 let mut line_starts = Vec::with_capacity(lines.len());
4406 let bytes = source.as_bytes();
4407 let mut offset = 0usize;
4408 for line in &lines {
4409 line_starts.push(offset);
4410 offset += line.len();
4411 if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
4412 offset += 2;
4413 } else if bytes.get(offset) == Some(&b'\n') {
4414 offset += 1;
4415 }
4416 }
4417 Self { lines, line_starts }
4418 }
4419
4420 fn len(&self) -> usize {
4421 debug_assert_eq!(self.lines.len(), self.line_starts.len());
4422 self.line_starts.len()
4423 }
4424}
4425
4426fn build_embed_text_with_lines(
4428 symbol: &Symbol,
4429 line_cache: &SourceLineCache<'_>,
4430 file: &Path,
4431 project_root: &Path,
4432) -> String {
4433 let relative = file
4434 .strip_prefix(project_root)
4435 .unwrap_or(file)
4436 .to_string_lossy();
4437
4438 let kind_label = match &symbol.kind {
4439 SymbolKind::Function => "function",
4440 SymbolKind::Class => "class",
4441 SymbolKind::Method => "method",
4442 SymbolKind::Struct => "struct",
4443 SymbolKind::Interface => "interface",
4444 SymbolKind::Enum => "enum",
4445 SymbolKind::TypeAlias => "type",
4446 SymbolKind::Variable => "variable",
4447 SymbolKind::Heading => "heading",
4448 SymbolKind::FileSummary => "file-summary",
4449 };
4450
4451 let name = &symbol.name;
4453 let mut text = format!(
4454 "name:{name} file:{} kind:{} name:{name}",
4455 relative, kind_label
4456 );
4457
4458 if let Some(sig) = &symbol.signature {
4459 text.push_str(&format!(" signature:{}", truncate_chars(sig, 400)));
4467 }
4468
4469 let start = (symbol.range.start_line as usize).min(line_cache.len());
4471 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4473 if start < end {
4474 let body: String = line_cache.lines[start..end]
4475 .iter()
4476 .take(15) .copied()
4478 .collect::<Vec<&str>>()
4479 .join("\n");
4480 let snippet = if body.len() > 300 {
4481 format!("{}...", &body[..body.floor_char_boundary(300)])
4482 } else {
4483 body
4484 };
4485 text.push_str(&format!(" body:{}", snippet));
4486 }
4487
4488 truncate_chars(&text, MAX_EMBED_TEXT_CHARS)
4493}
4494
4495#[cfg(test)]
4496fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
4497 let line_cache = SourceLineCache::new(source);
4498 build_embed_text_with_lines(symbol, &line_cache, file, project_root)
4499}
4500
4501const MAX_EMBED_TEXT_CHARS: usize = 1600;
4505
4506fn truncate_chars(value: &str, max_chars: usize) -> String {
4507 value.chars().take(max_chars).collect()
4508}
4509
4510fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
4511 let Some((start, first)) = line_cache
4512 .lines
4513 .iter()
4514 .enumerate()
4515 .find(|(_, line)| !line.trim().is_empty())
4516 else {
4517 return String::new();
4518 };
4519
4520 let trimmed = first.trim_start();
4521 if trimmed.starts_with("/**") {
4522 let mut comment = Vec::new();
4523 for line in line_cache.lines.iter().skip(start) {
4524 comment.push(*line);
4525 if line.contains("*/") {
4526 break;
4527 }
4528 }
4529 return truncate_chars(&comment.join("\n"), 200);
4530 }
4531
4532 if trimmed.starts_with("///") || trimmed.starts_with("//!") {
4533 let comment = line_cache
4534 .lines
4535 .iter()
4536 .skip(start)
4537 .take_while(|line| {
4538 let trimmed = line.trim_start();
4539 trimmed.starts_with("///") || trimmed.starts_with("//!")
4540 })
4541 .copied()
4542 .collect::<Vec<_>>()
4543 .join("\n");
4544 return truncate_chars(&comment, 200);
4545 }
4546
4547 String::new()
4548}
4549
4550pub fn build_file_summary_chunk(
4551 file: &Path,
4552 project_root: &Path,
4553 source: &str,
4554 top_exports: &[&str],
4555 top_export_signatures: &[Option<&str>],
4556) -> SemanticChunk {
4557 let line_cache = SourceLineCache::new(source);
4558 build_file_summary_chunk_with_lines(
4559 file,
4560 project_root,
4561 &line_cache,
4562 top_exports,
4563 top_export_signatures,
4564 )
4565}
4566
4567fn build_file_summary_chunk_with_lines(
4568 file: &Path,
4569 project_root: &Path,
4570 line_cache: &SourceLineCache<'_>,
4571 top_exports: &[&str],
4572 top_export_signatures: &[Option<&str>],
4573) -> SemanticChunk {
4574 let relative = file.strip_prefix(project_root).unwrap_or(file);
4575 let rel_path = relative.to_string_lossy();
4576 let parent_dir = relative
4577 .parent()
4578 .map(|parent| parent.to_string_lossy().to_string())
4579 .unwrap_or_default();
4580 let name = file
4581 .file_stem()
4582 .map(|stem| stem.to_string_lossy().to_string())
4583 .unwrap_or_default();
4584 let doc = first_leading_doc_comment(line_cache);
4585 let exports = top_exports
4586 .iter()
4587 .take(5)
4588 .copied()
4589 .collect::<Vec<_>>()
4590 .join(",");
4591 let snippet = if doc.is_empty() {
4592 top_export_signatures
4593 .first()
4594 .and_then(|signature| signature.as_deref())
4595 .map(|signature| truncate_chars(signature, 200))
4596 .unwrap_or_default()
4597 } else {
4598 doc.clone()
4599 };
4600
4601 SemanticChunk {
4602 file: file.to_path_buf(),
4603 name,
4604 qualified_name: None,
4605 kind: SymbolKind::FileSummary,
4606 start_line: 0,
4607 end_line: 0,
4608 exported: false,
4609 embed_text: truncate_chars(
4610 &format!(
4611 "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
4612 file.file_stem()
4613 .map(|stem| stem.to_string_lossy().to_string())
4614 .unwrap_or_default()
4615 ),
4616 MAX_EMBED_TEXT_CHARS,
4617 ),
4618 snippet,
4619 }
4620}
4621
4622pub fn is_semantic_indexed_extension(path: &Path) -> bool {
4623 if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
4624 return true;
4625 }
4626
4627 matches!(
4628 path.extension().and_then(|extension| extension.to_str()),
4629 Some(
4630 "ts" | "tsx"
4631 | "js"
4632 | "jsx"
4633 | "py"
4634 | "rs"
4635 | "go"
4636 | "c"
4637 | "h"
4638 | "cc"
4639 | "cpp"
4640 | "cxx"
4641 | "hpp"
4642 | "hh"
4643 | "zig"
4644 | "cs"
4645 | "sh"
4646 | "bash"
4647 | "zsh"
4648 | "inc"
4649 | "php"
4650 | "sol"
4651 | "scss"
4652 | "vue"
4653 | "yaml"
4654 | "yml"
4655 | "pas"
4656 | "pp"
4657 | "dpr"
4658 | "dpk"
4659 | "lpr"
4660 | "java"
4661 | "kt"
4662 | "kts"
4663 | "rb"
4664 | "swift"
4665 | "scala"
4666 | "sc"
4667 | "lua"
4668 | "pl"
4669 | "pm"
4670 | "t"
4671 | "r"
4672 | "R"
4673 | "groovy"
4674 | "gvy"
4675 | "gy"
4676 | "gsh"
4677 | "gradle"
4678 | "m"
4679 | "mm",
4680 )
4681 )
4682}
4683
4684fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
4685 if let Ok(canonical) = fs::canonicalize(path) {
4686 return canonical;
4687 }
4688
4689 let Some(parent) = path.parent() else {
4690 return path.to_path_buf();
4691 };
4692 let Some(file_name) = path.file_name() else {
4693 return path.to_path_buf();
4694 };
4695
4696 fs::canonicalize(parent)
4697 .map(|canonical_parent| canonical_parent.join(file_name))
4698 .unwrap_or_else(|_| path.to_path_buf())
4699}
4700
4701const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
4711
4712fn collect_semantic_file(
4713 project_root: &Path,
4714 file: &Path,
4715 phases: &mut SemanticCollectPhaseTimings,
4716) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
4717 let read_hash_started = Instant::now();
4718 let read_result = (|| {
4719 let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
4720 if !metadata.is_file() {
4721 return Err("not a regular file".to_string());
4722 }
4723 let mtime = metadata.modified().map_err(|error| error.to_string())?;
4724 let size = metadata.len();
4725
4726 if !is_semantic_indexed_extension(file) {
4727 return Err("unsupported file extension".to_string());
4728 }
4729 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4730
4731 let mut indexed_metadata = IndexedFileMetadata {
4732 mtime,
4733 size,
4734 content_hash: cache_freshness::zero_hash(),
4735 };
4736
4737 if size > MAX_SEMANTIC_FILE_BYTES {
4740 return Ok((indexed_metadata, lang, None));
4741 }
4742
4743 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4744 indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
4745 cache_freshness::hash_bytes(source.as_bytes())
4746 } else {
4747 cache_freshness::zero_hash()
4748 };
4749 Ok((indexed_metadata, lang, Some(source)))
4750 })();
4751 phases.read_hash += read_hash_started.elapsed();
4752 let (indexed_metadata, lang, source) = read_result?;
4753 let Some(source) = source else {
4754 return Ok((indexed_metadata, Vec::new()));
4755 };
4756
4757 let chunks = collect_file_chunks_from_source_timed(project_root, file, lang, &source, phases)?;
4758 Ok((indexed_metadata, chunks))
4759}
4760
4761#[cfg(test)]
4762fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
4763 if !is_semantic_indexed_extension(file) {
4764 return Err("unsupported file extension".to_string());
4765 }
4766 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4767 if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
4770 return Ok(Vec::new());
4771 }
4772 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4773 collect_file_chunks_from_source(project_root, file, lang, &source)
4774}
4775
4776#[cfg(test)]
4777fn collect_file_chunks_from_source(
4778 project_root: &Path,
4779 file: &Path,
4780 lang: crate::parser::LangId,
4781 source: &str,
4782) -> Result<Vec<SemanticChunk>, String> {
4783 collect_file_chunks_from_source_timed(
4784 project_root,
4785 file,
4786 lang,
4787 source,
4788 &mut SemanticCollectPhaseTimings::default(),
4789 )
4790}
4791
4792fn collect_file_chunks_from_source_timed(
4793 project_root: &Path,
4794 file: &Path,
4795 lang: crate::parser::LangId,
4796 source: &str,
4797 phases: &mut SemanticCollectPhaseTimings,
4798) -> Result<Vec<SemanticChunk>, String> {
4799 let parse_started = Instant::now();
4800 let tree_result =
4801 parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
4802 phases.parse += parse_started.elapsed();
4803 let tree = tree_result?;
4804
4805 let extract_started = Instant::now();
4806 let symbols_result =
4807 extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
4808 phases.extract += extract_started.elapsed();
4809 let symbols = symbols_result?;
4810
4811 let build_started = Instant::now();
4812 let chunks = symbols_to_chunks(file, &symbols, source, project_root);
4813 phases.build += build_started.elapsed();
4814 Ok(chunks)
4815}
4816
4817fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
4819 let start = (symbol.range.start_line as usize).min(line_cache.len());
4820 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4822 if start < end {
4823 let snippet_lines: Vec<&str> = line_cache.lines[start..end]
4824 .iter()
4825 .take(5)
4826 .copied()
4827 .collect();
4828 let mut snippet = snippet_lines.join("\n");
4829 if end - start > 5 {
4830 snippet.push_str("\n ...");
4831 }
4832 if snippet.len() > 300 {
4833 snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
4834 }
4835 snippet
4836 } else {
4837 String::new()
4838 }
4839}
4840
4841#[cfg(test)]
4842fn build_snippet(symbol: &Symbol, source: &str) -> String {
4843 let line_cache = SourceLineCache::new(source);
4844 build_snippet_with_lines(symbol, &line_cache)
4845}
4846
4847fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
4848 let mut parts = symbol
4849 .scope_chain
4850 .iter()
4851 .filter(|part| !part.is_empty())
4852 .cloned()
4853 .collect::<Vec<_>>();
4854 if !symbol.name.is_empty() {
4855 parts.push(symbol.name.clone());
4856 }
4857 (!parts.is_empty()).then(|| parts.join("."))
4858}
4859
4860fn symbols_to_chunks(
4862 file: &Path,
4863 symbols: &[Symbol],
4864 source: &str,
4865 project_root: &Path,
4866) -> Vec<SemanticChunk> {
4867 let line_cache = SourceLineCache::new(source);
4868 let mut chunks = Vec::new();
4869 let top_exports_with_signatures = symbols
4870 .iter()
4871 .filter(|symbol| {
4872 symbol.exported
4873 && symbol.parent.is_none()
4874 && !matches!(symbol.kind, SymbolKind::Heading)
4875 })
4876 .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
4877 .collect::<Vec<_>>();
4878
4879 let has_only_headings = !symbols.is_empty()
4880 && symbols
4881 .iter()
4882 .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
4883 if top_exports_with_signatures.len() <= 2 && !has_only_headings {
4884 let top_exports = top_exports_with_signatures
4885 .iter()
4886 .map(|(name, _)| *name)
4887 .collect::<Vec<_>>();
4888 let top_export_signatures = top_exports_with_signatures
4889 .iter()
4890 .map(|(_, signature)| *signature)
4891 .collect::<Vec<_>>();
4892 chunks.push(build_file_summary_chunk_with_lines(
4893 file,
4894 project_root,
4895 &line_cache,
4896 &top_exports,
4897 &top_export_signatures,
4898 ));
4899 }
4900
4901 for symbol in symbols {
4902 if matches!(symbol.kind, SymbolKind::Heading) {
4907 continue;
4908 }
4909
4910 let line_count = symbol
4912 .range
4913 .end_line
4914 .saturating_sub(symbol.range.start_line)
4915 + 1;
4916 if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
4917 continue;
4918 }
4919
4920 let embed_text = build_embed_text_with_lines(symbol, &line_cache, file, project_root);
4921 let snippet = build_snippet_with_lines(symbol, &line_cache);
4922
4923 chunks.push(SemanticChunk {
4924 file: file.to_path_buf(),
4925 name: symbol.name.clone(),
4926 qualified_name: qualified_name_for_symbol(symbol),
4927 kind: symbol.kind.clone(),
4928 start_line: symbol.range.start_line,
4929 end_line: symbol.range.end_line,
4930 exported: symbol.exported,
4931 embed_text,
4932 snippet,
4933 });
4934
4935 }
4938
4939 chunks
4940}
4941
4942fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
4943 b.0.partial_cmp(&a.0)
4944 .unwrap_or(std::cmp::Ordering::Equal)
4945 .then_with(|| a.1.cmp(&b.1))
4946}
4947
4948fn vector_norm(vector: &[f32]) -> f32 {
4950 vector.iter().map(|value| value * value).sum::<f32>().sqrt()
4951}
4952
4953fn dot_product(a: &[f32], b: &[f32]) -> f32 {
4954 a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
4955}
4956
4957#[cfg(test)]
4959fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
4960 if a.len() != b.len() {
4961 return 0.0;
4962 }
4963
4964 let mut dot = 0.0f32;
4965 let mut norm_a = 0.0f32;
4966 let mut norm_b = 0.0f32;
4967
4968 for i in 0..a.len() {
4969 dot += a[i] * b[i];
4970 norm_a += a[i] * a[i];
4971 norm_b += b[i] * b[i];
4972 }
4973
4974 let denom = norm_a.sqrt() * norm_b.sqrt();
4975 if denom == 0.0 {
4976 0.0
4977 } else {
4978 dot / denom
4979 }
4980}
4981
4982fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
4984 match kind {
4985 SymbolKind::Function => 0,
4986 SymbolKind::Class => 1,
4987 SymbolKind::Method => 2,
4988 SymbolKind::Struct => 3,
4989 SymbolKind::Interface => 4,
4990 SymbolKind::Enum => 5,
4991 SymbolKind::TypeAlias => 6,
4992 SymbolKind::Variable => 7,
4993 SymbolKind::Heading => 8,
4994 SymbolKind::FileSummary => 9,
4995 }
4996}
4997
4998fn u8_to_symbol_kind(v: u8) -> SymbolKind {
4999 match v {
5000 0 => SymbolKind::Function,
5001 1 => SymbolKind::Class,
5002 2 => SymbolKind::Method,
5003 3 => SymbolKind::Struct,
5004 4 => SymbolKind::Interface,
5005 5 => SymbolKind::Enum,
5006 6 => SymbolKind::TypeAlias,
5007 7 => SymbolKind::Variable,
5008 8 => SymbolKind::Heading,
5009 9 => SymbolKind::FileSummary,
5010 _ => SymbolKind::Heading,
5011 }
5012}
5013
5014#[cfg(test)]
5015mod tests {
5016 use super::*;
5017 use crate::config::{SemanticBackend, SemanticBackendConfig};
5018 use crate::parser::FileParser;
5019 use std::io::{Read, Write};
5020 use std::net::TcpListener;
5021 use std::process::Command;
5022 use std::thread;
5023 use tempfile::NamedTempFile;
5024
5025 #[cfg(unix)]
5028 const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
5029 "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
5030
5031 #[cfg(unix)]
5032 fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
5033 let fixture_root = project_root.join("tests/fixtures");
5034 let lf_root = tempfile::tempdir().expect("lf fixture root");
5039 let fixture_files = [
5040 "imports_rs.rs",
5041 "member_rs.rs",
5042 "sample.rs",
5043 "structure_rs.rs",
5044 ]
5045 .map(|name| {
5046 let source = std::fs::read_to_string(fixture_root.join(name))
5047 .expect("read fixture")
5048 .replace("\r\n", "\n");
5049 let path = lf_root.path().join("tests/fixtures").join(name);
5053 std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
5054 std::fs::write(&path, source).expect("write LF fixture");
5055 path
5056 });
5057 let project_root = lf_root.path();
5058 let (chunks, _) = SemanticIndex::collect_chunks(project_root, &fixture_files);
5059 let normalized = chunks
5060 .iter()
5061 .map(|chunk| {
5062 (
5063 chunk
5064 .file
5065 .strip_prefix(project_root)
5066 .unwrap()
5067 .to_string_lossy()
5068 .replace('\\', "/"),
5069 &chunk.name,
5070 &chunk.qualified_name,
5071 &chunk.kind,
5072 chunk.start_line,
5073 chunk.end_line,
5074 chunk.exported,
5075 &chunk.embed_text,
5076 &chunk.snippet,
5077 )
5078 })
5079 .collect::<Vec<_>>();
5080 let output = format!("{normalized:#?}");
5081 (
5082 chunks.len(),
5083 output.len(),
5084 blake3::hash(output.as_bytes()).to_hex().to_string(),
5085 )
5086 }
5087
5088 #[cfg(unix)]
5095 #[test]
5096 fn rust_semantic_fixture_output_matches_query_baseline() {
5097 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
5098 let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
5099 assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
5100 }
5101
5102 #[test]
5103 #[ignore = "manual single-file semantic collect phase benchmark"]
5104 fn profile_rust_single_file_semantic_collect() {
5105 let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
5106 let workspace_root = crate_root
5107 .parent()
5108 .and_then(Path::parent)
5109 .expect("workspace root");
5110 let files = [
5111 workspace_root.join("crates/aft/src/bash_background/registry.rs"),
5112 workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
5113 ];
5114
5115 for file in files {
5116 let source = fs::read_to_string(&file).expect("read benchmark source");
5117 for run in 1..=5 {
5118 let mut phases = SemanticCollectPhaseTimings::default();
5119 let started = Instant::now();
5120 let chunks = collect_file_chunks_from_source_timed(
5121 workspace_root,
5122 &file,
5123 crate::parser::LangId::Rust,
5124 &source,
5125 &mut phases,
5126 )
5127 .unwrap();
5128 eprintln!(
5129 "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
5130 file.strip_prefix(workspace_root).unwrap().display(),
5131 source.len(),
5132 started.elapsed(),
5133 phases.parse,
5134 phases.extract,
5135 phases.build,
5136 chunks.len()
5137 );
5138 }
5139 }
5140 }
5141
5142 #[test]
5143 fn semantic_index_includes_php_inc_and_scss_extensions() {
5144 for file in ["partial.inc", "index.php", "styles.scss"] {
5145 assert!(
5146 is_semantic_indexed_extension(Path::new(file)),
5147 "{file} should be semantic-index eligible"
5148 );
5149 }
5150 }
5151
5152 #[test]
5153 fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
5154 for file in [
5155 "script.groovy",
5156 "script.gvy",
5157 "script.gy",
5158 "shell.gsh",
5159 "build.gradle",
5160 "Jenkinsfile",
5161 ] {
5162 assert!(
5163 is_semantic_indexed_extension(Path::new(file)),
5164 "{file} should be semantic-index eligible"
5165 );
5166 }
5167 assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
5168 }
5169
5170 #[test]
5171 fn transient_marker_round_trips_and_classifies() {
5172 let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
5175 assert!(embedding_failure_is_transient(&marked));
5176 let clean = strip_transient_embedding_marker(&marked);
5177 assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
5178 assert!(clean.starts_with("openai compatible request failed:"));
5179
5180 for permanent in [
5183 "openai compatible request failed (HTTP 401): Unauthorized",
5184 "embedding dimension mismatch: index has 384, model returned 768",
5185 "too many files (>20000) for semantic indexing (max 20000)",
5186 ] {
5187 assert!(
5188 !embedding_failure_is_transient(permanent),
5189 "{permanent:?} must not be transient"
5190 );
5191 assert_eq!(strip_transient_embedding_marker(permanent), permanent);
5193 }
5194 }
5195
5196 #[test]
5197 fn send_error_transience_separates_connect_timeout_from_4xx() {
5198 assert!(is_retryable_embedding_status(
5200 reqwest::StatusCode::INTERNAL_SERVER_ERROR
5201 ));
5202 assert!(is_retryable_embedding_status(
5203 reqwest::StatusCode::TOO_MANY_REQUESTS
5204 ));
5205 assert!(!is_retryable_embedding_status(
5206 reqwest::StatusCode::UNAUTHORIZED
5207 ));
5208 assert!(!is_retryable_embedding_status(
5209 reqwest::StatusCode::BAD_REQUEST
5210 ));
5211 }
5212
5213 #[test]
5214 fn query_timeout_marker_round_trips_and_classifies() {
5215 let marked = format!(
5218 "{}openai compatible request failed: operation timed out",
5219 query_embedding_timeout_marker(3_000)
5220 );
5221 assert_eq!(query_embedding_timeout_budget(&marked), Some(3_000));
5222 let clean = strip_query_embedding_timeout_marker(&marked);
5223 assert!(!clean.contains(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX));
5224 assert!(clean.starts_with("openai compatible request failed:"));
5225
5226 for permanent in [
5229 "openai compatible request failed (HTTP 401): Unauthorized",
5230 "failed to embed query: embedding model was not initialized",
5231 "openai compatible request failed: connection refused",
5232 ] {
5233 assert_eq!(
5234 query_embedding_timeout_budget(permanent),
5235 None,
5236 "{permanent:?} must not classify as a query timeout"
5237 );
5238 assert_eq!(
5239 strip_query_embedding_timeout_marker(permanent),
5240 permanent,
5241 "stripping a marker-free string is a no-op"
5242 );
5243 }
5244 }
5245
5246 fn install_test_crypto_provider() {
5247 let _ = rustls::crypto::ring::default_provider().install_default();
5250 }
5251
5252 fn start_platform_verifier_tls_server() -> (String, NamedTempFile, thread::JoinHandle<()>) {
5253 install_test_crypto_provider();
5254 let ca_key = rcgen::KeyPair::generate().expect("generate test CA key");
5255 let mut ca_params = rcgen::CertificateParams::default();
5256 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
5257 ca_params.key_usages = vec![
5258 rcgen::KeyUsagePurpose::KeyCertSign,
5259 rcgen::KeyUsagePurpose::DigitalSignature,
5260 ];
5261 let ca_cert = ca_params
5262 .self_signed(&ca_key)
5263 .expect("generate test CA certificate");
5264
5265 let leaf_key = rcgen::KeyPair::generate().expect("generate test leaf key");
5266 let mut leaf_params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
5267 .expect("generate leaf parameters");
5268 leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
5269 leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
5270 let leaf_cert = leaf_params
5271 .signed_by(&leaf_key, &ca_cert, &ca_key)
5272 .expect("sign test leaf certificate");
5273
5274 let mut ca_file = NamedTempFile::new().expect("create test CA file");
5275 ca_file
5276 .write_all(ca_cert.pem().as_bytes())
5277 .expect("write test CA certificate");
5278
5279 let server_config = Arc::new(
5280 rustls::ServerConfig::builder()
5281 .with_no_client_auth()
5282 .with_single_cert(
5283 vec![rustls::pki_types::CertificateDer::from(
5284 leaf_cert.der().to_vec(),
5285 )],
5286 rustls::pki_types::PrivateKeyDer::Pkcs8(
5287 rustls::pki_types::PrivatePkcs8KeyDer::from(leaf_key.serialize_der()),
5288 ),
5289 )
5290 .expect("build test TLS server configuration"),
5291 );
5292 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test TLS server");
5293 let address = listener.local_addr().expect("read test TLS server address");
5294 let url = format!("https://localhost:{}/v1/embeddings", address.port());
5295 let handle = thread::spawn(move || {
5296 let expected_connections = if cfg!(target_os = "linux") { 2 } else { 1 };
5301 for _ in 0..expected_connections {
5302 let (stream, _) = listener.accept().expect("accept test TLS connection");
5303 stream
5304 .set_read_timeout(Some(Duration::from_secs(10)))
5305 .expect("set test TLS read timeout");
5306 let connection = rustls::ServerConnection::new(server_config.clone())
5307 .expect("create test TLS server connection");
5308 let mut tls_stream = rustls::StreamOwned::new(connection, stream);
5309 let mut request = [0_u8; 4096];
5310 if tls_stream.read(&mut request).is_ok() {
5311 let body = r#"{"data":[],"model":"test","object":"list"}"#;
5312 let response = format!(
5313 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5314 body.len(), body
5315 );
5316 let _ = tls_stream.write_all(response.as_bytes());
5317 tls_stream.conn.send_close_notify();
5318 let _ = tls_stream.flush();
5319 }
5320 }
5321 });
5322
5323 (url, ca_file, handle)
5324 }
5325
5326 fn run_platform_verifier_tls_child() {
5327 install_test_crypto_provider();
5328 let url = env::var("AFT_PLATFORM_VERIFIER_TLS_URL").expect("test TLS URL");
5329 let tls_config = crate::platform_tls::client_config().expect("build platform TLS config");
5330 let client = Client::builder()
5342 .timeout(Duration::from_secs(600))
5343 .use_preconfigured_tls(tls_config)
5344 .build()
5345 .expect("build test embedding client");
5346 let result = send_embedding_request(
5347 || client.post(&url).body("{}"),
5348 "openai compatible",
5349 EmbeddingRequestPolicy::Query(QueryBudget {
5350 timeout_ms: 600_000,
5351 }),
5352 );
5353
5354 #[cfg(target_os = "linux")]
5355 if env::var_os("SSL_CERT_FILE").is_some() {
5356 let body = result.expect("SSL_CERT_FILE should make the private CA trusted");
5357 assert!(
5358 body.contains("\"data\""),
5359 "unexpected embedding response: {body}"
5360 );
5361 return;
5362 }
5363
5364 let error = result.expect_err("the private CA must not be trusted on this path");
5365 let lower = error.to_ascii_lowercase();
5366 assert!(
5367 ["certificate", "unknownissuer", "unknown issuer", "trust"]
5368 .iter()
5369 .any(|marker| lower.contains(marker)),
5370 "the rendered source chain must include a certificate trust failure: {error}"
5371 );
5372 assert!(
5373 !embedding_failure_is_transient(&error),
5374 "certificate trust failures must not be retried: {error}"
5375 );
5376 }
5377
5378 #[test]
5379 fn platform_verifier_tls_client_subprocess() {
5380 if env::var_os("AFT_PLATFORM_VERIFIER_TLS_CHILD").is_some() {
5381 run_platform_verifier_tls_child();
5382 return;
5383 }
5384
5385 let _env_lock = crate::test_env::process_env_lock();
5393 let (url, _ca_file, server_handle) = start_platform_verifier_tls_server();
5394 let test_name = "semantic_index::tests::platform_verifier_tls_client_subprocess";
5395 #[cfg(target_os = "linux")]
5396 let ca_paths: &[Option<&Path>] = &[None, Some(_ca_file.path())];
5397 #[cfg(not(target_os = "linux"))]
5398 let ca_paths: &[Option<&Path>] = &[None];
5399
5400 for ca_path in ca_paths {
5401 let mut command = Command::new(env::current_exe().expect("test executable"));
5402 command
5403 .args(["--exact", test_name, "--nocapture"])
5404 .env("AFT_PLATFORM_VERIFIER_TLS_CHILD", "1")
5405 .env("AFT_PLATFORM_VERIFIER_TLS_URL", &url)
5406 .env_remove("SSL_CERT_FILE")
5407 .env_remove("SSL_CERT_DIR");
5408 if let Some(ca_path) = ca_path {
5409 command.env("SSL_CERT_FILE", ca_path);
5410 }
5411 let output = command.output().expect("run TLS child test");
5412 #[cfg(unix)]
5416 let signal = std::os::unix::process::ExitStatusExt::signal(&output.status);
5417 #[cfg(not(unix))]
5418 let signal: Option<i32> = None;
5419 assert!(
5420 output.status.success(),
5421 "TLS child failed: status={:?} code={:?} signal={:?}\nstdout:\n{}\nstderr:\n{}",
5422 output.status,
5423 output.status.code(),
5424 signal,
5425 String::from_utf8_lossy(&output.stdout),
5426 String::from_utf8_lossy(&output.stderr)
5427 );
5428 }
5429
5430 server_handle.join().expect("join test TLS server");
5431 }
5432
5433 #[test]
5434 fn local_backend_model_loading_body_is_transient() {
5435 for body in [
5438 r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
5439 r#"{"error":"model is loading, please wait"}"#,
5440 r#"{"error":"Model not loaded"}"#,
5441 "Loading model into memory",
5442 ] {
5443 assert!(
5444 embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
5445 "{body:?} should be body-transient"
5446 );
5447 }
5448
5449 for body in [
5453 r#"{"error":"invalid api key"}"#,
5454 r#"{"error":"model 'foo' not found"}"#,
5455 "Bad Request: unknown field",
5456 "Bad Request: invalid loading model option",
5457 r#"{"error":"unauthorized while model is being loaded by another account"}"#,
5458 ] {
5459 assert!(
5460 !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
5461 "{body:?} must not be body-transient"
5462 );
5463 }
5464
5465 assert!(
5466 !embedding_response_body_is_transient(
5467 reqwest::StatusCode::UNAUTHORIZED,
5468 r#"{"error":"model is loading, please wait"}"#
5469 ),
5470 "permanent auth failures must not become transient because of body text"
5471 );
5472 }
5473
5474 fn start_slow_embedding_server(
5475 expected_requests: usize,
5476 response_delay: Duration,
5477 ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
5478 let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
5479 listener
5480 .set_nonblocking(true)
5481 .expect("set slow server nonblocking");
5482 let addr = listener.local_addr().expect("slow embedding server addr");
5483 let requests = Arc::new(AtomicUsize::new(0));
5484 let requests_for_thread = Arc::clone(&requests);
5485 let handle = thread::spawn(move || {
5486 let deadline = Instant::now() + Duration::from_secs(10);
5487 let mut handlers = Vec::new();
5488 while requests_for_thread.load(Ordering::SeqCst) < expected_requests
5489 && Instant::now() < deadline
5490 {
5491 match listener.accept() {
5492 Ok((mut stream, _)) => {
5493 requests_for_thread.fetch_add(1, Ordering::SeqCst);
5494 handlers.push(thread::spawn(move || {
5495 let mut request = [0u8; 4096];
5496 let _ = stream.read(&mut request);
5497 thread::sleep(response_delay);
5498 let body =
5499 r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
5500 let response = format!(
5501 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5502 body.len(),
5503 body
5504 );
5505 let _ = stream.write_all(response.as_bytes());
5506 }));
5507 }
5508 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
5509 thread::sleep(Duration::from_millis(5));
5510 }
5511 Err(error) => panic!("accept slow embedding request: {error}"),
5512 }
5513 }
5514 for handler in handlers {
5515 handler.join().expect("slow embedding handler");
5516 }
5517 });
5518
5519 (format!("http://{addr}"), requests, handle)
5520 }
5521
5522 fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
5523 where
5524 F: Fn(String, String, String) -> String + Send + 'static,
5525 {
5526 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
5527 let addr = listener.local_addr().expect("local addr");
5528 let handle = thread::spawn(move || {
5529 let (mut stream, _) = listener.accept().expect("accept request");
5530 let mut buf = Vec::new();
5531 let mut chunk = [0u8; 4096];
5532 let mut header_end = None;
5533 let mut content_length = 0usize;
5534 loop {
5535 let n = stream.read(&mut chunk).expect("read request");
5536 if n == 0 {
5537 break;
5538 }
5539 buf.extend_from_slice(&chunk[..n]);
5540 if header_end.is_none() {
5541 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
5542 header_end = Some(pos + 4);
5543 let headers = String::from_utf8_lossy(&buf[..pos + 4]);
5544 for line in headers.lines() {
5545 if let Some(value) = line.strip_prefix("Content-Length:") {
5546 content_length = value.trim().parse::<usize>().unwrap_or(0);
5547 }
5548 }
5549 }
5550 }
5551 if let Some(end) = header_end {
5552 if buf.len() >= end + content_length {
5553 break;
5554 }
5555 }
5556 }
5557
5558 let end = header_end.expect("header terminator");
5559 let request = String::from_utf8_lossy(&buf[..end]).to_string();
5560 let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
5561 let mut lines = request.lines();
5562 let request_line = lines.next().expect("request line").to_string();
5563 let path = request_line
5564 .split_whitespace()
5565 .nth(1)
5566 .expect("request path")
5567 .to_string();
5568 let response_body = handler(request_line, path, body);
5569 let response = format!(
5570 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5571 response_body.len(),
5572 response_body
5573 );
5574 stream
5575 .write_all(response.as_bytes())
5576 .expect("write response");
5577 });
5578
5579 (format!("http://{}", addr), handle)
5580 }
5581
5582 fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
5583 let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
5584 listener
5585 .set_nonblocking(true)
5586 .expect("nonblocking listener");
5587 let addr = listener.local_addr().expect("local addr");
5588 let handle = thread::spawn(move || {
5589 let deadline = std::time::Instant::now() + Duration::from_secs(30);
5599 let mut accepted = 0usize;
5600 while accepted < attempts && std::time::Instant::now() < deadline {
5601 match listener.accept() {
5602 Ok((mut stream, _)) => {
5603 accepted += 1;
5604 let mut buf = [0u8; 4096];
5605 let _ = stream.read(&mut buf);
5613 let response = "HTTP/1.1 200 OK
5614Content-Type: application/json
5615Content-Length: 128
5616Connection: close
5617
5618{";
5619 let _ = stream.write_all(response.as_bytes());
5620 }
5621 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
5622 thread::sleep(Duration::from_millis(10));
5623 }
5624 Err(error) => panic!("accept request: {error}"),
5625 }
5626 }
5627 });
5628
5629 (format!("http://{}", addr), handle)
5630 }
5631
5632 #[test]
5633 fn response_body_read_failures_are_marked_transient() {
5634 let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
5635 let client = Client::builder()
5639 .timeout(Duration::from_secs(5))
5640 .build()
5641 .expect("client");
5642
5643 let error = send_embedding_request(
5644 || client.post(&url).body("{}"),
5645 "test backend",
5646 EmbeddingRequestPolicy::Build,
5647 )
5648 .expect_err("truncated body should fail");
5649
5650 handle.join().unwrap();
5651 assert!(
5652 embedding_failure_is_transient(&error),
5653 "body read failures should be transient-marked: {error}"
5654 );
5655 assert!(
5663 error.contains("response read failed") || error.contains("request failed"),
5664 "unexpected error shape: {error}"
5665 );
5666 }
5667
5668 fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
5669 Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
5670 }
5671
5672 fn write_rust_file(path: &Path, function_name: &str) {
5673 fs::write(
5674 path,
5675 format!("pub fn {function_name}() -> bool {{\n true\n}}\n"),
5676 )
5677 .unwrap();
5678 }
5679
5680 fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
5681 let mut embed = test_vector_for_texts;
5682 SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
5683 }
5684
5685 fn test_project_root() -> PathBuf {
5686 std::env::current_dir().unwrap()
5687 }
5688
5689 #[test]
5690 fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
5691 let project = tempfile::tempdir().expect("create project");
5692 let storage = tempfile::tempdir().expect("create storage");
5693 let source = project.path().join("lib.rs");
5694 write_rust_file(&source, "persisted_symbol");
5695 let populated = build_test_index(project.path(), std::slice::from_ref(&source));
5696 assert!(populated.write_to_disk(storage.path(), "project"));
5697
5698 let data_path = storage.path().join("semantic/project/semantic.bin");
5699 let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
5700 let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
5701 assert!(empty.write_to_disk(storage.path(), "project"));
5702 let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
5703 assert_ne!(empty_bytes, populated_bytes);
5704 let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
5705 .expect("decode explicit empty snapshot");
5706 assert_eq!(decoded.entry_count(), 0);
5707 for _ in 0..2 {
5708 let loaded = SemanticIndex::read_from_disk(
5709 storage.path(),
5710 "project",
5711 project.path(),
5712 false,
5713 None,
5714 )
5715 .expect("explicit empty snapshot remains loadable");
5716 assert_eq!(loaded.entry_count(), 0);
5717 }
5718 }
5719
5720 #[test]
5721 fn persistence_failure_is_reported_to_caller() {
5722 let project = tempfile::tempdir().expect("create project");
5723 let storage_parent = tempfile::tempdir().expect("create storage parent");
5724 let storage_file = storage_parent.path().join("not-a-directory");
5725 fs::write(&storage_file, b"occupied").expect("create blocking file");
5726 let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
5727
5728 assert!(!empty.write_to_disk(&storage_file, "project"));
5729 }
5730
5731 #[test]
5732 fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
5733 let root = test_project_root();
5734 let mut index = SemanticIndex::new(root.clone(), 3);
5735 assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
5736
5737 let entry = |name: &str| EmbeddingEntry {
5738 chunk: SemanticChunk {
5739 file: root.join(format!("{name}.rs")),
5740 name: name.to_string(),
5741 qualified_name: Some(format!("module::{name}")),
5742 kind: SymbolKind::Function,
5743 start_line: 0,
5744 end_line: 1,
5745 exported: true,
5746 embed_text: format!("function {name} body"),
5747 snippet: format!("fn {name}() {{}}"),
5748 },
5749 norm: vector_norm(&[1.0, 2.0, 3.0]),
5750 vector: vec![1.0, 2.0, 3.0],
5751 };
5752 index.entries.push(entry("one"));
5753 let one_entry = index.estimated_memory().estimated_bytes.unwrap();
5754 assert!(one_entry > 0);
5755 index.entries.push(entry("two"));
5756 let two_entries = index.estimated_memory().estimated_bytes.unwrap();
5757 assert!(two_entries > one_entry);
5758 }
5759
5760 fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
5761 index.file_mtimes.insert(file.to_path_buf(), mtime);
5762 index.file_sizes.insert(file.to_path_buf(), size);
5763 index
5764 .file_hashes
5765 .insert(file.to_path_buf(), cache_freshness::zero_hash());
5766 }
5767
5768 fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
5769 let mut buf = Vec::new();
5770 let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
5771 let encoded = fingerprint.as_string();
5772 if encoded.is_empty() {
5773 None
5774 } else {
5775 Some(encoded.into_bytes())
5776 }
5777 });
5778 let file_mtimes: Vec<_> = index
5779 .file_mtimes
5780 .iter()
5781 .filter_map(|(path, mtime)| {
5782 cache_relative_path(&index.project_root, path)
5783 .map(|relative| (relative, path, mtime))
5784 })
5785 .collect();
5786 let entries: Vec<_> = index
5787 .entries
5788 .iter()
5789 .filter_map(|entry| {
5790 cache_relative_path(&index.project_root, &entry.chunk.file)
5791 .map(|relative| (relative, entry))
5792 })
5793 .collect();
5794
5795 buf.push(SEMANTIC_INDEX_VERSION_V6);
5796 buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
5797 buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
5798 let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
5799 buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
5800 buf.extend_from_slice(fp_bytes_ref);
5801
5802 buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
5803 for (relative, path, mtime) in &file_mtimes {
5804 let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
5805 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
5806 buf.extend_from_slice(&path_bytes);
5807 let duration = mtime
5808 .duration_since(SystemTime::UNIX_EPOCH)
5809 .unwrap_or_default();
5810 buf.extend_from_slice(&duration.as_secs().to_le_bytes());
5811 buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
5812 let size = index.file_sizes.get(*path).copied().unwrap_or_default();
5813 buf.extend_from_slice(&size.to_le_bytes());
5814 let hash = index
5815 .file_hashes
5816 .get(*path)
5817 .copied()
5818 .unwrap_or_else(cache_freshness::zero_hash);
5819 buf.extend_from_slice(hash.as_bytes());
5820 }
5821
5822 for (relative, entry) in &entries {
5823 let c = &entry.chunk;
5824 let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
5825 buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
5826 buf.extend_from_slice(&file_bytes);
5827
5828 let name_bytes = c.name.as_bytes();
5829 buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
5830 buf.extend_from_slice(name_bytes);
5831
5832 buf.push(symbol_kind_to_u8(&c.kind));
5833 buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
5834 buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
5835 buf.push(c.exported as u8);
5836
5837 let snippet_bytes = c.snippet.as_bytes();
5838 buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
5839 buf.extend_from_slice(snippet_bytes);
5840
5841 let embed_bytes = c.embed_text.as_bytes();
5842 buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
5843 buf.extend_from_slice(embed_bytes);
5844
5845 for &val in &entry.vector {
5846 buf.extend_from_slice(&val.to_le_bytes());
5847 }
5848 }
5849
5850 buf
5851 }
5852
5853 #[derive(Default)]
5854 struct RecordingEmbedder {
5855 calls: Vec<Vec<String>>,
5856 }
5857
5858 impl RecordingEmbedder {
5859 fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
5860 let vectors = texts
5861 .iter()
5862 .map(|text| deterministic_test_vector(text))
5863 .collect();
5864 self.calls.push(texts);
5865 Ok(vectors)
5866 }
5867
5868 fn total_embedded_texts(&self) -> usize {
5869 self.calls.iter().map(Vec::len).sum()
5870 }
5871
5872 fn embedded_texts(&self) -> Vec<&str> {
5873 self.calls
5874 .iter()
5875 .flat_map(|batch| batch.iter().map(String::as_str))
5876 .collect()
5877 }
5878 }
5879
5880 fn deterministic_test_vector(text: &str) -> Vec<f32> {
5881 let hash = blake3::hash(text.as_bytes());
5882 let bytes = hash.as_bytes();
5883 vec![
5884 1.0,
5885 bytes[0] as f32 / 255.0,
5886 bytes[1] as f32 / 255.0,
5887 bytes[2] as f32 / 255.0,
5888 ]
5889 }
5890
5891 fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
5892 let mut embedder = RecordingEmbedder::default();
5893 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5894 SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
5895 }
5896
5897 fn force_stale(index: &mut SemanticIndex, file: &Path) {
5898 set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
5899 }
5900
5901 fn write_source(path: &Path, source: &str) {
5902 if let Some(parent) = path.parent() {
5903 fs::create_dir_all(parent).unwrap();
5904 }
5905 fs::write(path, source).unwrap();
5906 }
5907
5908 fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
5909 index
5910 .entries
5911 .iter()
5912 .filter(|entry| entry.chunk.file == file)
5913 .collect()
5914 }
5915
5916 fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
5917 index
5918 .entries
5919 .iter()
5920 .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
5921 .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
5922 }
5923
5924 fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
5925 index
5926 .entries
5927 .iter()
5928 .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
5929 .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
5930 }
5931
5932 #[test]
5933 fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
5934 let owner = tempfile::tempdir().unwrap();
5935 let storage = tempfile::tempdir().unwrap();
5936 let borrower_a = tempfile::tempdir().unwrap();
5937 let borrower_b = tempfile::tempdir().unwrap();
5938 let relative = Path::new("src/lib.rs");
5939 for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
5940 let file = root.join(relative);
5941 fs::create_dir_all(file.parent().unwrap()).unwrap();
5942 fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
5943 }
5944 let owner_file = owner.path().join(relative);
5945 let metadata = fs::metadata(&owner_file).unwrap();
5946 let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
5947 index.entries.push(EmbeddingEntry {
5948 chunk: SemanticChunk {
5949 file: owner_file.clone(),
5950 name: "shared_symbol".to_string(),
5951 qualified_name: None,
5952 kind: SymbolKind::Function,
5953 start_line: 0,
5954 end_line: 0,
5955 exported: true,
5956 embed_text: "shared symbol".to_string(),
5957 snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
5958 },
5959 norm: vector_norm(&[1.0, 0.0, 0.0]),
5960 vector: vec![1.0, 0.0, 0.0],
5961 });
5962 index.file_mtimes.insert(
5963 owner_file.clone(),
5964 metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
5965 );
5966 index.file_sizes.insert(owner_file.clone(), metadata.len());
5967 index.file_hashes.insert(
5968 owner_file,
5969 blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
5970 );
5971 index.set_fingerprint(SemanticIndexFingerprint {
5972 backend: "test".to_string(),
5973 model: "shared-base".to_string(),
5974 base_url: FALLBACK_BACKEND.to_string(),
5975 dimension: 3,
5976 chunking_version: default_chunking_version(),
5977 ..Default::default()
5978 });
5979 assert!(index.shared_base.is_none(), "owner indexes stay private");
5980
5981 let project_key = format!(
5982 "shared-base-{}",
5983 blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
5984 );
5985 let dir = storage.path().join("semantic").join(&project_key);
5986 fs::create_dir_all(&dir).unwrap();
5987 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5988 let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
5989 let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
5990 let a = SemanticIndex::read_from_disk_borrow_tolerant(
5991 storage.path(),
5992 &project_key,
5993 borrower_a.path(),
5994 )
5995 .unwrap();
5996 let b = SemanticIndex::read_from_disk_borrow_tolerant(
5997 storage.path(),
5998 &project_key,
5999 borrower_b.path(),
6000 )
6001 .unwrap();
6002 let a_base = a.shared_base.as_ref().unwrap();
6003 let b_base = b.shared_base.as_ref().unwrap();
6004 assert!(Arc::ptr_eq(a_base, b_base));
6005 assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
6006 assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
6007 assert_eq!(
6008 a.search(&[1.0, 0.0, 0.0], 1)[0].file,
6009 borrower_a.path().join(relative)
6010 );
6011 assert_eq!(
6012 b.search(&[1.0, 0.0, 0.0], 1)[0].file,
6013 borrower_b.path().join(relative)
6014 );
6015 assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
6016 assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
6017
6018 let weak = Arc::downgrade(a_base);
6019 let ctx = crate::context::AppContext::new(
6020 Box::new(crate::parser::TreeSitterProvider::new()),
6021 crate::config::Config {
6022 project_root: Some(borrower_a.path().to_path_buf()),
6023 ..crate::config::Config::default()
6024 },
6025 );
6026 *ctx.semantic_index()
6027 .write()
6028 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
6029 assert!(ctx.evict_idle_artifacts());
6030 assert!(
6031 weak.upgrade().is_some(),
6032 "the second borrower keeps the base live"
6033 );
6034 drop(b);
6035 assert!(
6036 weak.upgrade().is_none(),
6037 "the last borrower releases the base"
6038 );
6039 }
6040
6041 #[test]
6042 fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
6043 let owner = tempfile::tempdir().unwrap();
6044 let storage = tempfile::tempdir().unwrap();
6045 let borrower_a = tempfile::tempdir().unwrap();
6046 let borrower_b = tempfile::tempdir().unwrap();
6047 let relative = Path::new("src/lib.rs");
6048 for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
6049 let file = root.join(relative);
6050 fs::create_dir_all(file.parent().unwrap()).unwrap();
6051 fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
6052 }
6053 let owner_file = owner.path().join(relative);
6054 let metadata = fs::metadata(&owner_file).unwrap();
6055 let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
6056 index.entries.push(EmbeddingEntry {
6057 chunk: SemanticChunk {
6058 file: owner_file.clone(),
6059 name: "hash_guard".to_string(),
6060 qualified_name: None,
6061 kind: SymbolKind::Function,
6062 start_line: 0,
6063 end_line: 0,
6064 exported: true,
6065 embed_text: "hash guard".to_string(),
6066 snippet: "pub fn hash_guard() {}".to_string(),
6067 },
6068 norm: vector_norm(&[1.0, 0.0]),
6069 vector: vec![1.0, 0.0],
6070 });
6071 index.file_mtimes.insert(
6072 owner_file.clone(),
6073 metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
6074 );
6075 index.file_sizes.insert(owner_file.clone(), metadata.len());
6076 index
6077 .file_hashes
6078 .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
6079 index.set_fingerprint(SemanticIndexFingerprint {
6080 backend: "test".to_string(),
6081 model: "hash-guard".to_string(),
6082 base_url: FALLBACK_BACKEND.to_string(),
6083 dimension: 2,
6084 chunking_version: default_chunking_version(),
6085 ..Default::default()
6086 });
6087 let project_key = format!(
6088 "hash-fallback-{}",
6089 blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
6090 );
6091 let dir = storage.path().join("semantic").join(&project_key);
6092 fs::create_dir_all(&dir).unwrap();
6093 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
6094 let shared = SemanticIndex::read_from_disk_borrow_tolerant(
6095 storage.path(),
6096 &project_key,
6097 borrower_a.path(),
6098 )
6099 .unwrap();
6100 assert!(shared.shared_base.is_some());
6101
6102 let changed_vector = vec![0.0, 1.0];
6103 index.entries[0].norm = vector_norm(&changed_vector);
6104 index.entries[0].vector = changed_vector;
6105 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
6106 let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
6107 storage.path(),
6108 &project_key,
6109 borrower_b.path(),
6110 )
6111 .unwrap();
6112 assert!(
6113 fallback.shared_base.is_none(),
6114 "a different byte identity must not join the live shared generation"
6115 );
6116 drop(shared);
6117 }
6118
6119 #[test]
6120 fn borrow_only_root_skips_semantic_lock_and_persist() {
6121 let project = tempfile::tempdir().expect("project");
6122 let source = project.path().join("lib.rs");
6123 write_rust_file(&source, "borrow_only_symbol");
6124 let project_key = "shared-artifact-key".to_string();
6125 let storage = tempfile::tempdir().expect("storage");
6126 crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
6127
6128 let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
6129 .expect("borrow-only lock downgrade");
6130 let cache_dir = storage.path().join("semantic").join(&project_key);
6131 assert!(!cache_dir.join("cache.lock").exists());
6132
6133 let index = build_test_index(project.path(), &[source]);
6134 index.write_to_disk(storage.path(), &project_key);
6135
6136 assert!(!cache_dir.join("semantic.bin").exists());
6137 assert!(!cache_dir.exists());
6138 }
6139
6140 #[test]
6141 fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
6142 let temp = tempfile::tempdir().unwrap();
6143 let project_root = temp.path();
6144 let file = project_root.join("src/lib.rs");
6145 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
6146 write_source(&file, original);
6147
6148 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6149 let original_entry_count = index.entries.len();
6150 let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
6151
6152 write_source(&file, &format!("\n{original}"));
6153 force_stale(&mut index, &file);
6154
6155 let mut embedder = RecordingEmbedder::default();
6156 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6157 let mut progress = |_done: usize, _total: usize| {};
6158 let summary = index
6159 .refresh_stale_files(
6160 project_root,
6161 std::slice::from_ref(&file),
6162 &mut embed,
6163 16,
6164 &mut progress,
6165 )
6166 .unwrap();
6167
6168 assert_eq!(summary.changed, 1);
6169 assert_eq!(embedder.total_embedded_texts(), 0);
6170 assert_eq!(index.entries.len(), original_entry_count);
6171 let shifted_alpha = entry_by_name(&index, &file, "alpha");
6172 assert_eq!(shifted_alpha.chunk.start_line, 1);
6173 assert_eq!(shifted_alpha.vector, original_alpha_vector);
6174 }
6175
6176 #[test]
6177 fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
6178 let temp = tempfile::tempdir().unwrap();
6179 let project_root = temp.path();
6180 let file = project_root.join("src/lib.rs");
6181 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
6182 write_source(&file, original);
6183
6184 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6185 let mut serving_index = worker_index.clone();
6186 let original_entry_count = worker_index.entries.len();
6187
6188 write_source(&file, &format!("\n{original}"));
6189
6190 let mut embedder = RecordingEmbedder::default();
6191 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6192 let mut progress = |_done: usize, _total: usize| {};
6193 let update = worker_index
6194 .refresh_invalidated_files(
6195 project_root,
6196 std::slice::from_ref(&file),
6197 &mut embed,
6198 16,
6199 100,
6200 &mut progress,
6201 )
6202 .unwrap();
6203
6204 assert_eq!(embedder.total_embedded_texts(), 0);
6205 assert_eq!(update.added_entries.len(), original_entry_count);
6206 assert_eq!(worker_index.entries.len(), original_entry_count);
6207
6208 serving_index.apply_refresh_update(
6209 update.added_entries,
6210 update.updated_metadata,
6211 &update.completed_paths,
6212 );
6213
6214 assert_eq!(serving_index.entries.len(), original_entry_count);
6215 assert_eq!(
6216 entries_for_file(&serving_index, &file).len(),
6217 original_entry_count
6218 );
6219 assert_eq!(
6220 entry_by_name(&serving_index, &file, "alpha")
6221 .chunk
6222 .start_line,
6223 1
6224 );
6225 }
6226
6227 #[test]
6228 fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
6229 let temp = tempfile::tempdir().unwrap();
6230 let project_root = temp.path();
6231 let file = project_root.join("src/lib.rs");
6232 write_source(
6233 &file,
6234 "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
6235 );
6236
6237 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6238 let original_entry_count = index.entries.len();
6239 let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
6240
6241 write_source(
6242 &file,
6243 "pub fn alpha() -> i32 {\n 10\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
6244 );
6245
6246 let mut embedder = RecordingEmbedder::default();
6247 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6248 let mut progress = |_done: usize, _total: usize| {};
6249 let update = index
6250 .refresh_invalidated_files(
6251 project_root,
6252 std::slice::from_ref(&file),
6253 &mut embed,
6254 16,
6255 100,
6256 &mut progress,
6257 )
6258 .unwrap();
6259
6260 assert_eq!(embedder.total_embedded_texts(), 1);
6261 assert!(embedder.embedded_texts()[0].contains("name:alpha"));
6262 assert_eq!(update.added_entries.len(), original_entry_count);
6263 assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
6264 }
6265
6266 #[test]
6267 fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
6268 let temp = tempfile::tempdir().unwrap();
6269 let project_root = temp.path();
6270 let file = project_root.join("src/dupe.js");
6271 let one_duplicate = "function duplicate() {\n return 1;\n}\n";
6272 write_source(&file, one_duplicate);
6273
6274 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6275 let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
6276
6277 write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
6278
6279 let mut embedder = RecordingEmbedder::default();
6280 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6281 let mut progress = |_done: usize, _total: usize| {};
6282 index
6283 .refresh_invalidated_files(
6284 project_root,
6285 std::slice::from_ref(&file),
6286 &mut embed,
6287 16,
6288 100,
6289 &mut progress,
6290 )
6291 .unwrap();
6292
6293 let duplicate_entries = index
6294 .entries
6295 .iter()
6296 .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
6297 .collect::<Vec<_>>();
6298 assert_eq!(duplicate_entries.len(), 2);
6299 assert_eq!(embedder.total_embedded_texts(), 0);
6300 assert_eq!(duplicate_entries[0].vector, original_vector);
6301 assert_eq!(duplicate_entries[1].vector, original_vector);
6302 }
6303
6304 #[test]
6305 fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
6306 let temp = tempfile::tempdir().unwrap();
6307 let project_root = temp.path();
6308 let file = project_root.join("src/lib.rs");
6309 write_source(
6310 &file,
6311 "//! module docs v1\n\npub fn alpha() -> i32 {\n 1\n}\n",
6312 );
6313
6314 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6315 let summary_before = file_summary_entry(&index, &file).vector.clone();
6316
6317 write_source(
6318 &file,
6319 "//! module docs v1\n\npub fn alpha() -> i32 {\n 2\n}\n",
6320 );
6321 let mut body_embedder = RecordingEmbedder::default();
6322 let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
6323 let mut progress = |_done: usize, _total: usize| {};
6324 index
6325 .refresh_invalidated_files(
6326 project_root,
6327 std::slice::from_ref(&file),
6328 &mut body_embed,
6329 16,
6330 100,
6331 &mut progress,
6332 )
6333 .unwrap();
6334 assert_eq!(body_embedder.total_embedded_texts(), 1);
6335 assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
6336 assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
6337
6338 write_source(
6339 &file,
6340 "//! module docs v2\n\npub fn alpha() -> i32 {\n 2\n}\n",
6341 );
6342 let mut doc_embedder = RecordingEmbedder::default();
6343 let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
6344 index
6345 .refresh_invalidated_files(
6346 project_root,
6347 std::slice::from_ref(&file),
6348 &mut doc_embed,
6349 16,
6350 100,
6351 &mut progress,
6352 )
6353 .unwrap();
6354
6355 assert_eq!(doc_embedder.total_embedded_texts(), 1);
6356 assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
6357 assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
6358 }
6359
6360 #[test]
6361 fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
6362 let temp = tempfile::tempdir().unwrap();
6363 let project_root = temp.path();
6364 let file = project_root.join("src/lib.rs");
6365 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
6366
6367 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6368 let mut serving_index = worker_index.clone();
6369 fs::remove_file(&file).unwrap();
6370
6371 let mut embedder = RecordingEmbedder::default();
6372 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6373 let mut progress = |_done: usize, _total: usize| {};
6374 let update = worker_index
6375 .refresh_invalidated_files(
6376 project_root,
6377 std::slice::from_ref(&file),
6378 &mut embed,
6379 16,
6380 100,
6381 &mut progress,
6382 )
6383 .unwrap();
6384
6385 assert_eq!(update.summary.deleted, 1);
6386 assert_eq!(embedder.total_embedded_texts(), 0);
6387 assert!(worker_index.entries.is_empty());
6388
6389 serving_index.apply_refresh_update(
6390 update.added_entries,
6391 update.updated_metadata,
6392 &update.completed_paths,
6393 );
6394 assert!(serving_index.entries.is_empty());
6395 }
6396
6397 #[test]
6398 fn watcher_collect_failure_does_not_resurrect_stale_entries() {
6399 let temp = tempfile::tempdir().unwrap();
6400 let project_root = temp.path();
6401 let file = project_root.join("src/lib.rs");
6402 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
6403
6404 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6405 let mut serving_index = worker_index.clone();
6406 fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
6407
6408 let mut embedder = RecordingEmbedder::default();
6409 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6410 let mut progress = |_done: usize, _total: usize| {};
6411 let update = worker_index
6412 .refresh_invalidated_files(
6413 project_root,
6414 std::slice::from_ref(&file),
6415 &mut embed,
6416 16,
6417 100,
6418 &mut progress,
6419 )
6420 .unwrap();
6421
6422 assert_eq!(embedder.total_embedded_texts(), 0);
6423 assert!(update.added_entries.is_empty());
6424 assert!(worker_index.entries.is_empty());
6425 assert!(!worker_index.file_mtimes.contains_key(&file));
6426
6427 serving_index.apply_refresh_update(
6428 update.added_entries,
6429 update.updated_metadata,
6430 &update.completed_paths,
6431 );
6432 assert!(serving_index.entries.is_empty());
6433 assert!(!serving_index.file_mtimes.contains_key(&file));
6434 }
6435
6436 #[test]
6437 fn refresh_invalidated_cap_deferral_remains_file_count_based() {
6438 let temp = tempfile::tempdir().unwrap();
6439 let project_root = temp.path();
6440 let indexed = project_root.join("src/a.rs");
6441 let deferred = project_root.join("src/b.rs");
6442 write_source(&indexed, "pub fn alpha() -> i32 {\n 1\n}\n");
6443 write_source(&deferred, "pub fn beta() -> i32 {\n 2\n}\n");
6444
6445 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
6446 let mut embedder = RecordingEmbedder::default();
6447 let mut embed = |texts: Vec<String>| embedder.embed(texts);
6448 let mut progress = |_done: usize, _total: usize| {};
6449 let update = index
6450 .refresh_invalidated_files(
6451 project_root,
6452 std::slice::from_ref(&deferred),
6453 &mut embed,
6454 16,
6455 1,
6456 &mut progress,
6457 )
6458 .unwrap();
6459
6460 assert_eq!(update.summary.total_processed, 1);
6461 assert_eq!(update.summary.added, 0);
6462 assert_eq!(embedder.total_embedded_texts(), 0);
6463 assert_eq!(index.indexed_file_count(), 1);
6464 assert!(index.deferred_files.contains(&deferred));
6465 assert!(entries_for_file(&index, &deferred).is_empty());
6466 }
6467
6468 #[test]
6469 fn semantic_cache_serialization_skips_paths_outside_project_root() {
6470 let dir = tempfile::tempdir().expect("create temp dir");
6471 let project = fs::canonicalize(dir.path()).expect("canonical project");
6472 let outside = project.join("..").join("outside.rs");
6473 let mut index = SemanticIndex::new(project.clone(), 3);
6474 index
6475 .file_mtimes
6476 .insert(outside.clone(), SystemTime::UNIX_EPOCH);
6477 index.file_sizes.insert(outside.clone(), 1);
6478 index
6479 .file_hashes
6480 .insert(outside.clone(), cache_freshness::zero_hash());
6481 index.entries.push(EmbeddingEntry {
6482 chunk: SemanticChunk {
6483 file: outside,
6484 name: "outside".to_string(),
6485 qualified_name: None,
6486 kind: SymbolKind::Function,
6487 start_line: 0,
6488 end_line: 0,
6489 exported: false,
6490 embed_text: "outside".to_string(),
6491 snippet: "outside".to_string(),
6492 },
6493 norm: vector_norm(&[1.0, 0.0, 0.0]),
6494 vector: vec![1.0, 0.0, 0.0],
6495 });
6496
6497 let bytes = index.to_bytes();
6498 let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
6499 assert_eq!(loaded.entries.len(), 0);
6500 assert!(loaded.file_mtimes.is_empty());
6501 }
6502
6503 #[test]
6504 fn semantic_search_bounded_top_k_matches_reference_full_sort() {
6505 let project_root = test_project_root();
6506 let file = project_root.join("src/lib.rs");
6507 let mut index = SemanticIndex::new(project_root, 2);
6508 let entries = [
6509 ("alpha", vec![2.0, 0.0], false),
6510 ("beta", vec![0.0, 3.0], false),
6511 ("gamma", vec![4.0, 0.0], false),
6512 ("delta", vec![1.0, 1.0], true),
6513 ("epsilon", vec![-5.0, 0.0], false),
6514 ];
6515 for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
6516 index.entries.push(EmbeddingEntry {
6517 chunk: SemanticChunk {
6518 file: file.clone(),
6519 name: name.to_string(),
6520 qualified_name: None,
6521 kind: SymbolKind::Function,
6522 start_line: line as u32 + 1,
6523 end_line: line as u32 + 1,
6524 exported,
6525 embed_text: name.to_string(),
6526 snippet: format!("fn {name}() {{}}"),
6527 },
6528 norm: vector_norm(&vector),
6529 vector,
6530 });
6531 }
6532
6533 let query = vec![2.0, 0.0];
6534 let top_k = 4;
6535 let mut reference: Vec<(f32, usize)> = index
6536 .entries
6537 .iter()
6538 .enumerate()
6539 .map(|(idx, entry)| {
6540 let mut dot = 0.0f32;
6543 let mut query_squared_norm = 0.0f32;
6544 let mut entry_squared_norm = 0.0f32;
6545 for i in 0..query.len() {
6546 dot += query[i] * entry.vector[i];
6547 query_squared_norm += query[i] * query[i];
6548 entry_squared_norm += entry.vector[i] * entry.vector[i];
6549 }
6550 let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
6551 let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
6552 if entry.chunk.exported {
6553 score *= 1.1;
6554 }
6555 (score, idx)
6556 })
6557 .collect();
6558 reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
6559 let expected: Vec<(String, f32)> = reference
6560 .into_iter()
6561 .take(top_k)
6562 .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
6563 .collect();
6564
6565 let actual: Vec<(String, f32)> = index
6566 .search(&query, top_k)
6567 .into_iter()
6568 .map(|result| (result.name, result.score))
6569 .collect();
6570
6571 assert_eq!(
6572 actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
6573 expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
6574 );
6575 for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
6576 assert!((actual_score - expected_score).abs() < 1e-6);
6577 }
6578 assert_eq!(actual[0].0, "alpha");
6579 assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
6580 assert!(index.search(&query, 0).is_empty());
6581 }
6582
6583 #[test]
6584 fn test_cosine_similarity_identical() {
6585 let a = vec![1.0, 0.0, 0.0];
6586 let b = vec![1.0, 0.0, 0.0];
6587 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
6588 }
6589
6590 #[test]
6591 fn test_cosine_similarity_orthogonal() {
6592 let a = vec![1.0, 0.0, 0.0];
6593 let b = vec![0.0, 1.0, 0.0];
6594 assert!(cosine_similarity(&a, &b).abs() < 0.001);
6595 }
6596
6597 #[test]
6598 fn test_cosine_similarity_opposite() {
6599 let a = vec![1.0, 0.0, 0.0];
6600 let b = vec![-1.0, 0.0, 0.0];
6601 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
6602 }
6603
6604 #[test]
6605 fn test_serialization_roundtrip() {
6606 let project_root = test_project_root();
6607 let file = project_root.join("src/main.rs");
6608 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
6609 index.entries.push(EmbeddingEntry {
6610 chunk: SemanticChunk {
6611 file: file.clone(),
6612 name: "handle_request".to_string(),
6613 qualified_name: None,
6614 kind: SymbolKind::Function,
6615 start_line: 10,
6616 end_line: 25,
6617 exported: true,
6618 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6619 snippet: "fn handle_request() {\n // ...\n}".to_string(),
6620 },
6621 norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
6622 vector: vec![0.1, 0.2, 0.3, 0.4],
6623 });
6624 index.dimension = 4;
6625 index
6626 .file_mtimes
6627 .insert(file.clone(), SystemTime::UNIX_EPOCH);
6628 index.file_sizes.insert(file, 0);
6629 index.set_fingerprint(SemanticIndexFingerprint {
6630 backend: "fastembed".to_string(),
6631 model: "all-MiniLM-L6-v2".to_string(),
6632 base_url: FALLBACK_BACKEND.to_string(),
6633 dimension: 4,
6634 chunking_version: default_chunking_version(),
6635 ..Default::default()
6636 });
6637
6638 let bytes = index.to_bytes();
6639 let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
6640
6641 assert_eq!(restored.entries.len(), 1);
6642 assert_eq!(restored.entries[0].chunk.name, "handle_request");
6643 assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
6644 assert_eq!(
6645 restored.entries[0].norm,
6646 vector_norm(&restored.entries[0].vector)
6647 );
6648 assert_eq!(restored.dimension, 4);
6649 assert_eq!(restored.backend_label(), Some("fastembed"));
6650 assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
6651 }
6652
6653 #[test]
6654 fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
6655 let storage = tempfile::tempdir().expect("create storage dir");
6656 let project = storage.path().join("project");
6657 fs::create_dir_all(project.join("src")).expect("create project src");
6658 let file = project.join("src/lib.rs");
6659 fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
6660 let project_root = fs::canonicalize(&project).expect("canonical project");
6661 let file = fs::canonicalize(&file).expect("canonical file");
6662
6663 let mut index = SemanticIndex::new(project_root.clone(), 3);
6664 let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
6665 index.file_mtimes.insert(file.clone(), mtime);
6666 index.file_sizes.insert(file.clone(), 42);
6667 index
6668 .file_hashes
6669 .insert(file.clone(), cache_freshness::zero_hash());
6670 index.entries.push(EmbeddingEntry {
6671 chunk: SemanticChunk {
6672 file: file.clone(),
6673 name: "alpha".to_string(),
6674 qualified_name: Some("Service.alpha".to_string()),
6675 kind: SymbolKind::Function,
6676 start_line: 0,
6677 end_line: 0,
6678 exported: true,
6679 embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
6680 snippet: "pub fn alpha() {}".to_string(),
6681 },
6682 norm: vector_norm(&[0.1, 0.2, 0.3]),
6683 vector: vec![0.1, 0.2, 0.3],
6684 });
6685 index.entries.push(EmbeddingEntry {
6686 chunk: SemanticChunk {
6687 file: file.clone(),
6688 name: "beta".to_string(),
6689 qualified_name: Some("Service.beta".to_string()),
6690 kind: SymbolKind::Function,
6691 start_line: 1,
6692 end_line: 1,
6693 exported: true,
6694 embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
6695 snippet: "pub fn beta() {}".to_string(),
6696 },
6697 norm: vector_norm(&[0.4, 0.5, 0.6]),
6698 vector: vec![0.4, 0.5, 0.6],
6699 });
6700 let fingerprint = SemanticIndexFingerprint {
6701 backend: "fastembed".to_string(),
6702 model: "all-MiniLM-L6-v2".to_string(),
6703 base_url: FALLBACK_BACKEND.to_string(),
6704 dimension: 3,
6705 chunking_version: default_chunking_version(),
6706 ..Default::default()
6707 };
6708 let fingerprint_before = fingerprint.as_string();
6709 index.set_fingerprint(fingerprint.clone());
6710
6711 let legacy_bytes = legacy_semantic_index_bytes(&index);
6712 assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
6713 let legacy_dir = storage.path().join("semantic/legacy-proj");
6714 fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
6715 let legacy_path = legacy_dir.join("semantic.bin");
6716 fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
6717 let legacy_loaded = SemanticIndex::read_from_disk(
6718 storage.path(),
6719 "legacy-proj",
6720 &project_root,
6721 false,
6722 Some(&fingerprint_before),
6723 )
6724 .expect("load v6 semantic index");
6725 assert!(
6726 legacy_path.exists(),
6727 "compatible V6 cache must not be deleted"
6728 );
6729 assert!(legacy_loaded
6730 .entries
6731 .iter()
6732 .all(|entry| entry.chunk.qualified_name.is_none()));
6733 assert_eq!(
6734 legacy_loaded.fingerprint().unwrap().as_string(),
6735 fingerprint_before
6736 );
6737
6738 let v7_bytes = index.to_bytes();
6739 assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
6740 assert_ne!(v7_bytes, legacy_bytes);
6741 let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
6742 assert_eq!(
6743 restored.entries[0].chunk.qualified_name.as_deref(),
6744 Some("Service.alpha")
6745 );
6746 assert_eq!(
6747 restored.entries[1].chunk.qualified_name.as_deref(),
6748 Some("Service.beta")
6749 );
6750 assert_eq!(
6751 restored.fingerprint().unwrap().as_string(),
6752 fingerprint_before
6753 );
6754
6755 index.write_to_disk(storage.path(), "proj");
6756 let data_path = storage.path().join("semantic/proj/semantic.bin");
6757 let persisted = fs::read(&data_path).expect("read semantic.bin");
6758 assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
6759
6760 let loaded = SemanticIndex::read_from_disk(
6761 storage.path(),
6762 "proj",
6763 &project_root,
6764 false,
6765 Some(&fingerprint_before),
6766 )
6767 .expect("load semantic index");
6768 assert_eq!(loaded.entries.len(), index.entries.len());
6769 assert_eq!(loaded.dimension, index.dimension);
6770 assert_eq!(
6771 loaded.fingerprint().unwrap().as_string(),
6772 fingerprint_before
6773 );
6774 assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
6775 assert_eq!(loaded.file_sizes.get(&file), Some(&42));
6776 assert_eq!(
6777 loaded.file_hashes.get(&file),
6778 Some(&cache_freshness::zero_hash())
6779 );
6780 for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
6781 assert_eq!(actual.chunk.file, expected.chunk.file);
6782 assert_eq!(actual.chunk.name, expected.chunk.name);
6783 assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
6784 assert_eq!(actual.chunk.kind, expected.chunk.kind);
6785 assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
6786 assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
6787 assert_eq!(actual.chunk.exported, expected.chunk.exported);
6788 assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
6789 assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
6790 assert_eq!(actual.vector, expected.vector);
6791 }
6792 assert_eq!(loaded.to_bytes(), persisted);
6793 assert_eq!(fingerprint.as_string(), fingerprint_before);
6794 }
6795
6796 #[test]
6797 fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
6798 let cases = [
6799 (SymbolKind::Function, 0),
6800 (SymbolKind::Class, 1),
6801 (SymbolKind::Method, 2),
6802 (SymbolKind::Struct, 3),
6803 (SymbolKind::Interface, 4),
6804 (SymbolKind::Enum, 5),
6805 (SymbolKind::TypeAlias, 6),
6806 (SymbolKind::Variable, 7),
6807 (SymbolKind::Heading, 8),
6808 (SymbolKind::FileSummary, 9),
6809 ];
6810
6811 for (kind, encoded) in cases {
6812 assert_eq!(symbol_kind_to_u8(&kind), encoded);
6813 assert_eq!(u8_to_symbol_kind(encoded), kind);
6814 }
6815 }
6816
6817 #[test]
6818 fn test_search_top_k() {
6819 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6820 index.dimension = 3;
6821
6822 for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
6824 let mut vec = vec![0.0f32; 3];
6825 vec[i] = 1.0; index.entries.push(EmbeddingEntry {
6827 chunk: SemanticChunk {
6828 file: PathBuf::from("/src/lib.rs"),
6829 name: name.to_string(),
6830 qualified_name: None,
6831 kind: SymbolKind::Function,
6832 start_line: (i * 10 + 1) as u32,
6833 end_line: (i * 10 + 5) as u32,
6834 exported: true,
6835 embed_text: format!("kind:function name:{}", name),
6836 snippet: format!("fn {}() {{}}", name),
6837 },
6838 norm: vector_norm(&vec),
6839 vector: vec,
6840 });
6841 }
6842
6843 let query = vec![0.9, 0.1, 0.0];
6845 let results = index.search(&query, 2);
6846
6847 assert_eq!(results.len(), 2);
6848 assert_eq!(results[0].name, "auth"); assert!(results[0].score > results[1].score);
6850 }
6851
6852 #[test]
6853 fn test_empty_index_search() {
6854 let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6855 let results = index.search(&[0.1, 0.2, 0.3], 10);
6856 assert!(results.is_empty());
6857 }
6858
6859 #[test]
6860 fn single_line_symbol_builds_non_empty_snippet() {
6861 let symbol = Symbol {
6862 name: "answer".to_string(),
6863 kind: SymbolKind::Variable,
6864 range: crate::symbols::Range {
6865 start_line: 0,
6866 start_col: 0,
6867 end_line: 0,
6868 end_col: 24,
6869 },
6870 signature: Some("const answer = 42".to_string()),
6871 scope_chain: Vec::new(),
6872 exported: true,
6873 parent: None,
6874 };
6875 let source = "export const answer = 42;\n";
6876
6877 let snippet = build_snippet(&symbol, source);
6878
6879 assert_eq!(snippet, "export const answer = 42;");
6880 }
6881
6882 #[test]
6883 fn optimized_file_chunk_collection_matches_file_parser_path() {
6884 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
6885 let file = project_root.join("src/semantic_index.rs");
6886 let source = std::fs::read_to_string(&file).unwrap();
6887
6888 let mut legacy_parser = FileParser::new();
6889 let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
6890 let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
6891
6892 let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
6893
6894 assert_eq!(
6895 chunk_fingerprint(&optimized_chunks),
6896 chunk_fingerprint(&legacy_chunks)
6897 );
6898 }
6899
6900 #[test]
6901 fn collect_file_chunks_indexes_java_symbols() {
6902 let dir = tempfile::tempdir().unwrap();
6903 let file = dir.path().join("Greeter.java");
6904 std::fs::write(
6905 &file,
6906 r#"package example;
6907
6908public class Greeter {
6909 public String greet(String name) {
6910 return "Hello, " + name;
6911 }
6912}
6913"#,
6914 )
6915 .unwrap();
6916
6917 let chunks = collect_file_chunks(dir.path(), &file).unwrap();
6918
6919 assert!(
6920 !chunks.is_empty(),
6921 "Java file should produce semantic chunks"
6922 );
6923 assert!(
6924 chunks
6925 .iter()
6926 .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
6927 "Java class symbol should be chunked: {chunks:?}"
6928 );
6929 assert!(
6930 chunks
6931 .iter()
6932 .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
6933 "Java method symbol should be chunked: {chunks:?}"
6934 );
6935 }
6936
6937 fn chunk_fingerprint(
6938 chunks: &[SemanticChunk],
6939 ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
6940 chunks
6941 .iter()
6942 .map(|chunk| {
6943 (
6944 chunk.name.clone(),
6945 chunk.kind.clone(),
6946 chunk.start_line,
6947 chunk.end_line,
6948 chunk.exported,
6949 chunk.embed_text.clone(),
6950 chunk.snippet.clone(),
6951 )
6952 })
6953 .collect()
6954 }
6955
6956 #[test]
6957 fn collect_file_chunks_skips_oversized_file() {
6958 let dir = tempfile::tempdir().unwrap();
6959 let big = dir.path().join("huge.ts");
6960 let filler = "export const x = 1;\n"
6962 .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
6963 std::fs::write(&big, &filler).unwrap();
6964 assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
6965
6966 let chunks = collect_file_chunks(dir.path(), &big).unwrap();
6969 assert!(chunks.is_empty(), "oversized file must yield no chunks");
6970
6971 let small = dir.path().join("small.ts");
6973 std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
6974 let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
6975 assert!(!small_chunks.is_empty(), "small file should still chunk");
6976 }
6977
6978 #[test]
6979 fn rejects_oversized_dimension_during_deserialization() {
6980 let mut bytes = Vec::new();
6981 bytes.push(1u8);
6982 bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
6983 bytes.extend_from_slice(&0u32.to_le_bytes());
6984 bytes.extend_from_slice(&0u32.to_le_bytes());
6985
6986 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6987 }
6988
6989 #[test]
6990 fn rejects_oversized_entry_count_during_deserialization() {
6991 let mut bytes = Vec::new();
6992 bytes.push(1u8);
6993 bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
6994 bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
6995 bytes.extend_from_slice(&0u32.to_le_bytes());
6996
6997 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6998 }
6999
7000 fn add_invalidation_fixture_entry(index: &mut SemanticIndex, file: PathBuf, ordinal: u64) {
7001 index.entries.push(EmbeddingEntry::new(
7002 SemanticChunk {
7003 file: file.clone(),
7004 name: format!("symbol_{ordinal}"),
7005 qualified_name: None,
7006 kind: SymbolKind::Function,
7007 start_line: ordinal as u32,
7008 end_line: ordinal as u32 + 1,
7009 exported: false,
7010 embed_text: format!("symbol {ordinal}"),
7011 snippet: format!("fn symbol_{ordinal}() {{}}"),
7012 },
7013 vec![ordinal as f32 + 1.0, 1.0],
7014 ));
7015 let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(ordinal + 1);
7016 index.file_mtimes.insert(file.clone(), mtime);
7017 index.file_sizes.insert(file.clone(), ordinal + 10);
7018 index
7019 .file_hashes
7020 .insert(file, blake3::hash(&ordinal.to_le_bytes()));
7021 }
7022
7023 #[test]
7024 fn batch_invalidation_matches_sequential_calls_with_one_retain_pass() {
7025 let temp = tempfile::tempdir().unwrap();
7026 let project_root = temp.path().canonicalize().unwrap();
7027 let mut source = SemanticIndex::new(project_root.clone(), 2);
7028 let files = (0..8)
7029 .map(|ordinal| {
7030 let file = project_root.join(format!("file_{ordinal}.rs"));
7031 fs::write(&file, format!("fn symbol_{ordinal}() {{}}\n")).unwrap();
7032 add_invalidation_fixture_entry(&mut source, file.clone(), ordinal);
7033 file
7034 })
7035 .collect::<Vec<_>>();
7036 let invalidated = vec![files[1].clone(), files[3].clone(), files[6].clone()];
7037
7038 let shared = Arc::new(source.into_shared_base().unwrap());
7039 let mut shared_batched =
7040 SemanticIndex::from_shared_base(project_root.clone(), Arc::clone(&shared));
7041 shared_batched.invalidate_files(&invalidated);
7042 let mut source = SemanticIndex::from_shared_base(project_root, shared);
7043 source.materialize_shared_base();
7044 let mut sequential = source.clone();
7045 let mut batched = source;
7046 for file in &invalidated {
7047 sequential.invalidate_file(file);
7048 }
7049 batched.invalidate_files(&invalidated);
7050
7051 assert!(sequential.shared_base.is_none());
7052 assert!(batched.shared_base.is_none());
7053 assert!(shared_batched.shared_base.is_none());
7054 assert_eq!(batched.to_bytes(), sequential.to_bytes());
7055 assert_eq!(shared_batched.file_mtimes, batched.file_mtimes);
7056 assert_eq!(shared_batched.file_sizes, batched.file_sizes);
7057 assert_eq!(shared_batched.file_hashes, batched.file_hashes);
7058 assert_eq!(
7059 format!("{:?}", shared_batched.entries),
7060 format!("{:?}", batched.entries)
7061 );
7062 assert_eq!(
7063 sequential.removal_retain_passes_for_test(),
7064 invalidated.len()
7065 );
7066 assert_eq!(batched.removal_retain_passes_for_test(), 1);
7067 assert_eq!(shared_batched.removal_retain_passes_for_test(), 1);
7068 }
7069
7070 #[cfg(unix)]
7071 #[test]
7072 fn batch_invalidation_removes_raw_and_canonical_alias_metadata() {
7073 use std::os::unix::fs::symlink;
7074
7075 let temp = tempfile::tempdir().unwrap();
7076 let project_root = temp.path().canonicalize().unwrap();
7077 let real_dir = project_root.join("real");
7078 let alias_dir = project_root.join("alias");
7079 fs::create_dir(&real_dir).unwrap();
7080 symlink(&real_dir, &alias_dir).unwrap();
7081 let real_file = real_dir.join("lib.rs");
7082 let alias_file = alias_dir.join("lib.rs");
7083 let untouched = project_root.join("untouched.rs");
7084 fs::write(&real_file, "fn aliased() {}\n").unwrap();
7085 fs::write(&untouched, "fn untouched() {}\n").unwrap();
7086 assert_eq!(fs::canonicalize(&alias_file).unwrap(), real_file);
7087
7088 let mut index = SemanticIndex::new(project_root, 2);
7089 add_invalidation_fixture_entry(&mut index, alias_file.clone(), 1);
7090 add_invalidation_fixture_entry(&mut index, real_file.clone(), 2);
7091 add_invalidation_fixture_entry(&mut index, untouched.clone(), 3);
7092 let mut sequential = index.clone();
7093 sequential.invalidate_file(&alias_file);
7094 index.invalidate_files(std::slice::from_ref(&alias_file));
7095
7096 assert_eq!(index.to_bytes(), sequential.to_bytes());
7097 assert!(index
7098 .entries
7099 .iter()
7100 .all(|entry| entry.chunk.file != alias_file && entry.chunk.file != real_file));
7101 assert!(!index.file_mtimes.contains_key(&alias_file));
7102 assert!(!index.file_mtimes.contains_key(&real_file));
7103 assert!(index.file_mtimes.contains_key(&untouched));
7104 assert!(!index.file_sizes.contains_key(&alias_file));
7105 assert!(!index.file_sizes.contains_key(&real_file));
7106 assert!(index.file_sizes.contains_key(&untouched));
7107 assert!(!index.file_hashes.contains_key(&alias_file));
7108 assert!(!index.file_hashes.contains_key(&real_file));
7109 assert!(index.file_hashes.contains_key(&untouched));
7110 assert_eq!(index.removal_retain_passes_for_test(), 1);
7111 }
7112
7113 #[test]
7114 fn invalidate_file_removes_entries_and_mtime() {
7115 let target = PathBuf::from("/src/main.rs");
7116 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7117 index.entries.push(EmbeddingEntry {
7118 chunk: SemanticChunk {
7119 file: target.clone(),
7120 name: "main".to_string(),
7121 qualified_name: None,
7122 kind: SymbolKind::Function,
7123 start_line: 0,
7124 end_line: 1,
7125 exported: false,
7126 embed_text: "main".to_string(),
7127 snippet: "fn main() {}".to_string(),
7128 },
7129 norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
7130 vector: vec![1.0; DEFAULT_DIMENSION],
7131 });
7132 index
7133 .file_mtimes
7134 .insert(target.clone(), SystemTime::UNIX_EPOCH);
7135 index.file_sizes.insert(target.clone(), 0);
7136
7137 index.invalidate_file(&target);
7138
7139 assert!(index.entries.is_empty());
7140 assert!(!index.file_mtimes.contains_key(&target));
7141 assert!(!index.file_sizes.contains_key(&target));
7142 }
7143
7144 #[test]
7145 fn refresh_missing_changed_file_is_purged_after_collect() {
7146 let temp = tempfile::tempdir().unwrap();
7147 let project_root = temp.path();
7148 let file = project_root.join("src/lib.rs");
7149 fs::create_dir_all(file.parent().unwrap()).unwrap();
7150 write_rust_file(&file, "vanished_symbol");
7151
7152 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7153 let original_size = *index.file_sizes.get(&file).unwrap();
7154 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
7155 fs::remove_file(&file).unwrap();
7156
7157 let mut embed = test_vector_for_texts;
7158 let mut progress = |_done: usize, _total: usize| {};
7159 let summary = index
7160 .refresh_stale_files(
7161 project_root,
7162 std::slice::from_ref(&file),
7163 &mut embed,
7164 8,
7165 &mut progress,
7166 )
7167 .unwrap();
7168
7169 assert_eq!(summary.changed, 0);
7170 assert_eq!(summary.added, 0);
7171 assert_eq!(summary.deleted, 1);
7172 assert!(index.entries.is_empty());
7173 assert!(!index.file_mtimes.contains_key(&file));
7174 assert!(!index.file_sizes.contains_key(&file));
7175 assert!(!index.file_hashes.contains_key(&file));
7176 }
7177
7178 #[test]
7179 fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
7180 let temp = tempfile::tempdir().unwrap();
7181 let project_root = temp.path();
7182 let file = project_root.join("src/lib.rs");
7183 fs::create_dir_all(file.parent().unwrap()).unwrap();
7184 write_rust_file(&file, "kept_symbol");
7185
7186 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7187 let original_entry_count = index.entries.len();
7188 let original_mtime = *index.file_mtimes.get(&file).unwrap();
7189 let original_size = *index.file_sizes.get(&file).unwrap();
7190
7191 let stale_mtime = SystemTime::UNIX_EPOCH;
7192 set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
7193 fs::remove_file(&file).unwrap();
7194 fs::create_dir(&file).unwrap();
7195
7196 let mut embed = test_vector_for_texts;
7197 let mut progress = |_done: usize, _total: usize| {};
7198 let summary = index
7199 .refresh_stale_files(
7200 project_root,
7201 std::slice::from_ref(&file),
7202 &mut embed,
7203 8,
7204 &mut progress,
7205 )
7206 .unwrap();
7207
7208 assert_eq!(summary.changed, 0);
7209 assert_eq!(summary.added, 0);
7210 assert_eq!(summary.deleted, 0);
7211 assert_eq!(index.entries.len(), original_entry_count);
7212 assert!(index
7213 .entries
7214 .iter()
7215 .any(|entry| entry.chunk.name == "kept_symbol"));
7216 assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
7217 assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
7218 assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
7219 }
7220
7221 #[test]
7222 fn refresh_never_indexed_file_error_does_not_record_mtime() {
7223 let temp = tempfile::tempdir().unwrap();
7224 let project_root = temp.path();
7225 let missing = project_root.join("src/missing.rs");
7226 fs::create_dir_all(missing.parent().unwrap()).unwrap();
7227
7228 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7229 let mut embed = test_vector_for_texts;
7230 let mut progress = |_done: usize, _total: usize| {};
7231 let summary = index
7232 .refresh_stale_files(
7233 project_root,
7234 std::slice::from_ref(&missing),
7235 &mut embed,
7236 8,
7237 &mut progress,
7238 )
7239 .unwrap();
7240
7241 assert_eq!(summary.added, 0);
7242 assert_eq!(summary.changed, 0);
7243 assert_eq!(summary.deleted, 0);
7244 assert!(!index.file_mtimes.contains_key(&missing));
7245 assert!(!index.file_sizes.contains_key(&missing));
7246 assert!(index.entries.is_empty());
7247 }
7248
7249 #[test]
7250 fn refresh_reports_added_for_new_files() {
7251 let temp = tempfile::tempdir().unwrap();
7252 let project_root = temp.path();
7253 let existing = project_root.join("src/lib.rs");
7254 let added = project_root.join("src/new.rs");
7255 fs::create_dir_all(existing.parent().unwrap()).unwrap();
7256 write_rust_file(&existing, "existing_symbol");
7257 write_rust_file(&added, "added_symbol");
7258
7259 let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
7260 let mut embed = test_vector_for_texts;
7261 let mut progress = |_done: usize, _total: usize| {};
7262 let summary = index
7263 .refresh_stale_files(
7264 project_root,
7265 &[existing.clone(), added.clone()],
7266 &mut embed,
7267 8,
7268 &mut progress,
7269 )
7270 .unwrap();
7271
7272 assert_eq!(summary.added, 1);
7273 assert_eq!(summary.changed, 0);
7274 assert_eq!(summary.deleted, 0);
7275 assert_eq!(summary.total_processed, 2);
7276 assert!(index.file_mtimes.contains_key(&added));
7277 assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
7278 }
7279
7280 #[test]
7281 fn refresh_reports_deleted_for_removed_files() {
7282 let temp = tempfile::tempdir().unwrap();
7283 let project_root = temp.path();
7284 let deleted = project_root.join("src/deleted.rs");
7285 fs::create_dir_all(deleted.parent().unwrap()).unwrap();
7286 write_rust_file(&deleted, "deleted_symbol");
7287
7288 let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
7289 fs::remove_file(&deleted).unwrap();
7290
7291 let mut embed = test_vector_for_texts;
7292 let mut progress = |_done: usize, _total: usize| {};
7293 let summary = index
7294 .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
7295 .unwrap();
7296
7297 assert_eq!(summary.deleted, 1);
7298 assert_eq!(summary.changed, 0);
7299 assert_eq!(summary.added, 0);
7300 assert_eq!(summary.total_processed, 1);
7301 assert!(!index.file_mtimes.contains_key(&deleted));
7302 assert!(index.entries.is_empty());
7303 }
7304
7305 #[test]
7306 fn refresh_reports_changed_for_modified_files() {
7307 let temp = tempfile::tempdir().unwrap();
7308 let project_root = temp.path();
7309 let file = project_root.join("src/lib.rs");
7310 fs::create_dir_all(file.parent().unwrap()).unwrap();
7311 write_rust_file(&file, "old_symbol");
7312
7313 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7314 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
7315 write_rust_file(&file, "new_symbol");
7316
7317 let mut embed = test_vector_for_texts;
7318 let mut progress = |_done: usize, _total: usize| {};
7319 let summary = index
7320 .refresh_stale_files(
7321 project_root,
7322 std::slice::from_ref(&file),
7323 &mut embed,
7324 8,
7325 &mut progress,
7326 )
7327 .unwrap();
7328
7329 assert_eq!(summary.changed, 1);
7330 assert_eq!(summary.added, 0);
7331 assert_eq!(summary.deleted, 0);
7332 assert_eq!(summary.total_processed, 1);
7333 assert!(index
7334 .entries
7335 .iter()
7336 .any(|entry| entry.chunk.name == "new_symbol"));
7337 assert!(!index
7338 .entries
7339 .iter()
7340 .any(|entry| entry.chunk.name == "old_symbol"));
7341 }
7342
7343 #[test]
7344 fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
7345 let temp = tempfile::tempdir().unwrap();
7346 let project_root = temp.path();
7347 let file = project_root.join("src/lib.rs");
7348 fs::create_dir_all(file.parent().unwrap()).unwrap();
7349 write_rust_file(&file, "clean_symbol");
7350
7351 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7352 let original_entries = index.entries.len();
7353 let mut embed_called = false;
7354 let mut embed = |texts: Vec<String>| {
7355 embed_called = true;
7356 test_vector_for_texts(texts)
7357 };
7358 let mut progress = |_done: usize, _total: usize| {};
7359 let summary = index
7360 .refresh_stale_files(
7361 project_root,
7362 std::slice::from_ref(&file),
7363 &mut embed,
7364 8,
7365 &mut progress,
7366 )
7367 .unwrap();
7368
7369 assert!(summary.is_noop());
7370 assert_eq!(summary.total_processed, 1);
7371 assert!(!embed_called);
7372 assert_eq!(index.entries.len(), original_entries);
7373 }
7374
7375 #[test]
7376 fn detects_missing_onnx_runtime_from_dynamic_load_error() {
7377 let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
7378
7379 assert!(is_onnx_runtime_unavailable(message));
7380 }
7381
7382 #[test]
7383 fn formats_missing_onnx_runtime_with_install_hint() {
7384 let message = format_embedding_init_error(
7385 "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
7386 );
7387
7388 assert!(message.starts_with("ONNX Runtime not found. Install via:"));
7389 assert!(message.contains("Original error:"));
7390 }
7391
7392 #[test]
7393 fn interactive_query_budget_is_independent_from_build_timeout() {
7394 let mut config = SemanticBackendConfig {
7395 backend: SemanticBackend::OpenAiCompatible,
7396 model: "test-embedding".to_string(),
7397 base_url: Some("http://127.0.0.1:9".to_string()),
7398 api_key_env: None,
7399 timeout_ms: 0,
7400 query_timeout_ms: 0,
7401 max_batch_size: 64,
7402 max_files: 20_000,
7403 ..Default::default()
7404 };
7405
7406 let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
7407 let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
7408 assert_eq!(
7409 build_model.timeout_ms(),
7410 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
7411 "background build keeps the longer default embedding timeout"
7412 );
7413 assert_eq!(
7414 query_model.timeout_ms(),
7415 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
7416 "a query-created model remains safe for later background build reuse"
7417 );
7418 assert_eq!(
7419 QueryBudget::from_config(&config).timeout_ms(),
7420 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
7421 );
7422
7423 config.timeout_ms = 60_000;
7424 assert_eq!(
7425 QueryBudget::from_config(&config).timeout_ms(),
7426 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7427 "the build timeout must not affect interactive requests"
7428 );
7429
7430 config.query_timeout_ms = 700;
7431 assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
7432 }
7433
7434 #[test]
7435 fn background_build_embedding_keeps_retry_ladder() {
7436 let (base_url, requests, handle) =
7437 start_slow_embedding_server(EMBEDDING_REQUEST_MAX_ATTEMPTS, Duration::from_millis(300));
7438 let config = SemanticBackendConfig {
7439 backend: SemanticBackend::OpenAiCompatible,
7440 model: "test-embedding".to_string(),
7441 base_url: Some(base_url),
7442 api_key_env: None,
7443 timeout_ms: 100,
7444 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7445 max_batch_size: 64,
7446 max_files: 20_000,
7447 ..Default::default()
7448 };
7449 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7450
7451 let error = model
7452 .embed(vec!["slow build batch".to_string()])
7453 .expect_err("all slow build attempts should time out");
7454 handle.join().expect("slow embedding server");
7455
7456 assert!(embedding_failure_is_transient(&error), "error: {error}");
7457 assert_eq!(
7458 requests.load(Ordering::SeqCst),
7459 EMBEDDING_REQUEST_MAX_ATTEMPTS,
7460 "background builds must retain the existing retry ladder"
7461 );
7462 }
7463
7464 #[test]
7465 fn openai_compatible_backend_embeds_with_mock_server() {
7466 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
7467 assert!(request_line.starts_with("POST "));
7468 assert_eq!(path, "/v1/embeddings");
7469 "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
7470 });
7471
7472 let config = SemanticBackendConfig {
7473 backend: SemanticBackend::OpenAiCompatible,
7474 model: "test-embedding".to_string(),
7475 base_url: Some(base_url),
7476 api_key_env: None,
7477 timeout_ms: 5_000,
7478 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7479 max_batch_size: 64,
7480 max_files: 20_000,
7481 ..Default::default()
7482 };
7483
7484 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7485 let vectors = model
7486 .embed(vec!["hello".to_string(), "world".to_string()])
7487 .unwrap();
7488
7489 assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
7490 handle.join().unwrap();
7491 }
7492
7493 #[test]
7503 fn openai_compatible_request_has_single_content_type_header() {
7504 use std::sync::{Arc, Mutex};
7505 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
7506 let captured_for_thread = Arc::clone(&captured);
7507
7508 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
7509 let addr = listener.local_addr().expect("local addr");
7510 let handle = thread::spawn(move || {
7511 let (mut stream, _) = listener.accept().expect("accept");
7512 let mut buf = Vec::new();
7513 let mut chunk = [0u8; 4096];
7514 let mut header_end = None;
7515 let mut content_length = 0usize;
7516 loop {
7517 let n = stream.read(&mut chunk).expect("read");
7518 if n == 0 {
7519 break;
7520 }
7521 buf.extend_from_slice(&chunk[..n]);
7522 if header_end.is_none() {
7523 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
7524 header_end = Some(pos + 4);
7525 for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
7526 if let Some(value) = line.strip_prefix("Content-Length:") {
7527 content_length = value.trim().parse::<usize>().unwrap_or(0);
7528 }
7529 }
7530 }
7531 }
7532 if let Some(end) = header_end {
7533 if buf.len() >= end + content_length {
7534 break;
7535 }
7536 }
7537 }
7538 *captured_for_thread.lock().unwrap() = buf;
7539 let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
7540 let response = format!(
7541 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7542 body.len(),
7543 body
7544 );
7545 let _ = stream.write_all(response.as_bytes());
7546 });
7547
7548 let config = SemanticBackendConfig {
7549 backend: SemanticBackend::OpenAiCompatible,
7550 model: "text-embedding-3-small".to_string(),
7551 base_url: Some(format!("http://{}", addr)),
7552 api_key_env: None,
7553 timeout_ms: 5_000,
7554 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7555 max_batch_size: 64,
7556 max_files: 20_000,
7557 ..Default::default()
7558 };
7559 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7560 let _ = model.embed(vec!["probe".to_string()]).unwrap();
7561 handle.join().unwrap();
7562
7563 let bytes = captured.lock().unwrap().clone();
7564 let request = String::from_utf8_lossy(&bytes);
7565
7566 let content_type_lines = request
7569 .lines()
7570 .filter(|line| {
7571 let lower = line.to_ascii_lowercase();
7572 lower.starts_with("content-type:")
7573 })
7574 .count();
7575 assert_eq!(
7576 content_type_lines, 1,
7577 "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
7578 );
7579
7580 assert!(
7583 request.contains(r#""model":"text-embedding-3-small""#),
7584 "request body should contain model field; full request:\n{request}",
7585 );
7586 }
7587
7588 #[test]
7589 fn ollama_backend_embeds_with_mock_server() {
7590 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
7591 assert!(request_line.starts_with("POST "));
7592 assert_eq!(path, "/api/embed");
7593 "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
7594 });
7595
7596 let config = SemanticBackendConfig {
7597 backend: SemanticBackend::Ollama,
7598 model: "embeddinggemma".to_string(),
7599 base_url: Some(base_url),
7600 api_key_env: None,
7601 timeout_ms: 5_000,
7602 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7603 max_batch_size: 64,
7604 max_files: 20_000,
7605 ..Default::default()
7606 };
7607
7608 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7609 let vectors = model
7610 .embed(vec!["hello".to_string(), "world".to_string()])
7611 .unwrap();
7612
7613 assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
7614 handle.join().unwrap();
7615 }
7616
7617 #[test]
7618 fn read_from_disk_rejects_fingerprint_mismatch() {
7619 let storage = tempfile::tempdir().unwrap();
7620 let project_key = "proj";
7621
7622 let project_root = test_project_root();
7623 let file = project_root.join("src/main.rs");
7624 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
7625 index.entries.push(EmbeddingEntry {
7626 chunk: SemanticChunk {
7627 file: file.clone(),
7628 name: "handle_request".to_string(),
7629 qualified_name: None,
7630 kind: SymbolKind::Function,
7631 start_line: 10,
7632 end_line: 25,
7633 exported: true,
7634 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
7635 snippet: "fn handle_request() {}".to_string(),
7636 },
7637 norm: vector_norm(&[0.1, 0.2, 0.3]),
7638 vector: vec![0.1, 0.2, 0.3],
7639 });
7640 index.dimension = 3;
7641 index
7642 .file_mtimes
7643 .insert(file.clone(), SystemTime::UNIX_EPOCH);
7644 index.file_sizes.insert(file, 0);
7645 index.set_fingerprint(SemanticIndexFingerprint {
7646 backend: "openai_compatible".to_string(),
7647 model: "test-embedding".to_string(),
7648 base_url: "http://127.0.0.1:1234/v1".to_string(),
7649 dimension: 3,
7650 chunking_version: default_chunking_version(),
7651 ..Default::default()
7652 });
7653 index.write_to_disk(storage.path(), project_key);
7654
7655 let data_path = storage
7656 .path()
7657 .join("semantic")
7658 .join(project_key)
7659 .join("semantic.bin");
7660 let before = fs::read(&data_path).unwrap();
7661
7662 let matching = index.fingerprint().unwrap().as_string();
7663 assert!(SemanticIndex::read_from_disk(
7664 storage.path(),
7665 project_key,
7666 &project_root,
7667 false,
7668 Some(&matching),
7669 )
7670 .is_some());
7671
7672 let mismatched = SemanticIndexFingerprint {
7673 backend: "ollama".to_string(),
7674 model: "embeddinggemma".to_string(),
7675 base_url: "http://127.0.0.1:11434".to_string(),
7676 dimension: 3,
7677 chunking_version: default_chunking_version(),
7678 ..Default::default()
7679 }
7680 .as_string();
7681 assert!(SemanticIndex::read_from_disk(
7682 storage.path(),
7683 project_key,
7684 &project_root,
7685 false,
7686 Some(&mismatched),
7687 )
7688 .is_none());
7689 assert_eq!(fs::read(&data_path).unwrap(), before);
7690 }
7691
7692 #[test]
7693 fn synapse_fingerprint_pin_matches_only_equivalent_alias_at_same_epoch() {
7694 let cached = SemanticIndexFingerprint {
7695 backend: "synapse".to_string(),
7696 model: "configured-model".to_string(),
7697 dimension: 768,
7698 chunking_version: 2,
7699 synapse_fingerprint: Some("fp-old".to_string()),
7700 synapse_table_epoch: Some(9),
7701 ..Default::default()
7702 };
7703 let mut served = cached.clone();
7704 served.synapse_fingerprint = Some("fp-current".to_string());
7705 served.synapse_equivalent_to = vec!["fp-old".to_string()];
7706 assert!(cached.matches_expected(&served.as_string()));
7707
7708 served.synapse_table_epoch = Some(10);
7709 assert!(!cached.matches_expected(&served.as_string()));
7710 }
7711
7712 #[test]
7713 fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
7714 let cached = SemanticIndexFingerprint {
7715 backend: "openai_compatible".to_string(),
7716 model: "cached-model".to_string(),
7717 base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
7718 dimension: 3,
7719 chunking_version: 2,
7720 ..Default::default()
7721 };
7722 let current = SemanticIndexFingerprint {
7723 backend: "ollama".to_string(),
7724 model: "current-model".to_string(),
7725 base_url: "https://example.org/api/embed".to_string(),
7726 dimension: 4,
7727 chunking_version: 3,
7728 ..Default::default()
7729 };
7730
7731 let details = format_fingerprint_mismatch_details(Some(&cached), ¤t);
7732
7733 assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
7734 assert!(details.contains("model cached=cached-model current=current-model"));
7735 assert!(details.contains("base_url host cached=example.com current=example.org"));
7736 assert!(details.contains("dimension cached=3 current=4"));
7737 assert!(details.contains("chunking version cached=2 current=3"));
7738 assert!(!details.contains("secret"));
7739 assert!(!details.contains("/v1/embeddings"));
7740 assert!(!details.contains("/api/embed"));
7741 }
7742
7743 #[test]
7744 fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
7745 let storage = tempfile::tempdir().unwrap();
7746 let project_key = "proj-v3";
7747 let dir = storage.path().join("semantic").join(project_key);
7748 fs::create_dir_all(&dir).unwrap();
7749
7750 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7751 index.entries.push(EmbeddingEntry {
7752 chunk: SemanticChunk {
7753 file: PathBuf::from("/src/main.rs"),
7754 name: "handle_request".to_string(),
7755 qualified_name: None,
7756 kind: SymbolKind::Function,
7757 start_line: 0,
7758 end_line: 0,
7759 exported: true,
7760 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
7761 snippet: "fn handle_request() {}".to_string(),
7762 },
7763 norm: vector_norm(&[0.1, 0.2, 0.3]),
7764 vector: vec![0.1, 0.2, 0.3],
7765 });
7766 index.dimension = 3;
7767 index
7768 .file_mtimes
7769 .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
7770 index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
7771 let fingerprint = SemanticIndexFingerprint {
7772 backend: "fastembed".to_string(),
7773 model: "test".to_string(),
7774 base_url: FALLBACK_BACKEND.to_string(),
7775 dimension: 3,
7776 chunking_version: default_chunking_version(),
7777 ..Default::default()
7778 };
7779 index.set_fingerprint(fingerprint.clone());
7780
7781 let mut bytes = index.to_bytes();
7782 bytes[0] = SEMANTIC_INDEX_VERSION_V3;
7783 let data_path = dir.join("semantic.bin");
7784 fs::write(&data_path, &bytes).unwrap();
7785
7786 assert!(SemanticIndex::read_from_disk(
7787 storage.path(),
7788 project_key,
7789 &test_project_root(),
7790 false,
7791 Some(&fingerprint.as_string())
7792 )
7793 .is_none());
7794 assert_eq!(fs::read(&data_path).unwrap(), bytes);
7795 }
7796
7797 fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
7798 crate::symbols::Symbol {
7799 name: name.to_string(),
7800 kind,
7801 range: crate::symbols::Range {
7802 start_line: start,
7803 start_col: 0,
7804 end_line: end,
7805 end_col: 0,
7806 },
7807 signature: None,
7808 scope_chain: Vec::new(),
7809 exported: false,
7810 parent: None,
7811 }
7812 }
7813
7814 #[test]
7815 fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
7816 let project_root = PathBuf::from("/proj");
7817 let file = project_root.join("src/engine.ts");
7818 let source = "class Index {\n}\n";
7819 let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
7820 symbol.scope_chain = vec!["Engine".to_string()];
7821 symbol.signature = Some("class Index".to_string());
7822 let embed_text = build_embed_text(&symbol, source, &file, &project_root);
7823
7824 let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
7825 let chunk = chunks
7826 .iter()
7827 .find(|chunk| chunk.name == "Index")
7828 .expect("class chunk");
7829
7830 assert_eq!(chunk.name, "Index");
7831 assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
7832 assert_eq!(chunk.embed_text, embed_text);
7833 assert!(!chunk.embed_text.contains("Engine.Index"));
7834 }
7835
7836 #[test]
7841 fn symbols_to_chunks_skips_heading_symbols() {
7842 let project_root = PathBuf::from("/proj");
7843 let file = project_root.join("README.md");
7844 let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
7845
7846 let symbols = vec![
7847 make_symbol(SymbolKind::Heading, "Title", 0, 2),
7848 make_symbol(SymbolKind::Heading, "Section", 4, 6),
7849 ];
7850
7851 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
7852 assert!(
7853 chunks.is_empty(),
7854 "Heading symbols must be filtered out before embedding; got {} chunk(s)",
7855 chunks.len()
7856 );
7857 }
7858
7859 #[test]
7866 fn build_embed_text_clamps_oversized_signature() {
7867 let project_root = PathBuf::from("/proj");
7868 let file = project_root.join("cronjob.yaml");
7869 let huge_sig = "kubectl ".repeat(2000); let source = "apiVersion: batch/v1\nkind: CronJob\n";
7871
7872 let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
7873 symbol.signature = Some(huge_sig);
7874
7875 let text = build_embed_text(&symbol, source, &file, &project_root);
7876 assert!(
7877 text.chars().count() <= MAX_EMBED_TEXT_CHARS,
7878 "embed_text must be clamped to {} chars, got {}",
7879 MAX_EMBED_TEXT_CHARS,
7880 text.chars().count()
7881 );
7882 }
7883
7884 #[test]
7888 fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
7889 let project_root = PathBuf::from("/proj");
7890 let file = project_root.join("src/lib.rs");
7891 let source = "pub fn handle_request() -> bool {\n true\n}\n";
7892
7893 let symbols = vec![
7894 make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
7896 make_symbol(SymbolKind::Function, "handle_request", 0, 2),
7897 make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
7898 ];
7899
7900 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
7901 assert_eq!(
7902 chunks.len(),
7903 3,
7904 "Expected file-summary + 2 code chunks (Function + Struct), got {}",
7905 chunks.len()
7906 );
7907 let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
7908 assert!(chunks
7909 .iter()
7910 .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
7911 assert!(names.contains(&"handle_request"));
7912 assert!(names.contains(&"AuthService"));
7913 assert!(
7914 !names.contains(&"doc heading"),
7915 "Heading symbol leaked into chunks: {names:?}"
7916 );
7917 }
7918
7919 #[test]
7920 fn validate_ssrf_allows_loopback_hostnames() {
7921 for host in &[
7924 "http://localhost",
7925 "http://localhost:8080",
7926 "http://localhost:11434", "http://localhost.localdomain",
7928 "http://foo.localhost",
7929 ] {
7930 assert!(
7931 validate_base_url_no_ssrf(host).is_ok(),
7932 "Expected {host} to be allowed (loopback), got: {:?}",
7933 validate_base_url_no_ssrf(host)
7934 );
7935 }
7936 }
7937
7938 #[test]
7939 fn validate_ssrf_allows_loopback_ips() {
7940 for url in &[
7943 "http://127.0.0.1",
7944 "http://127.0.0.1:11434", "http://127.0.0.1:8080",
7946 "http://127.1.2.3",
7947 ] {
7948 let result = validate_base_url_no_ssrf(url);
7949 assert!(
7950 result.is_ok(),
7951 "Expected {url} to be allowed (loopback), got: {:?}",
7952 result
7953 );
7954 }
7955 }
7956
7957 #[test]
7958 fn validate_ssrf_rejects_private_non_loopback_ips() {
7959 for url in &[
7964 "http://192.168.1.1",
7965 "http://10.0.0.1",
7966 "http://172.16.0.1",
7967 "http://169.254.169.254",
7968 "http://100.64.0.1",
7969 ] {
7970 let result = validate_base_url_no_ssrf(url);
7971 assert!(
7972 result.is_err(),
7973 "Expected {url} to be rejected (non-loopback private), got: {:?}",
7974 result
7975 );
7976 }
7977 }
7978
7979 #[test]
7980 fn validate_ssrf_rejects_mdns_local_hostnames() {
7981 for host in &[
7984 "http://printer.local",
7985 "http://nas.local:8080",
7986 "http://homelab.local",
7987 ] {
7988 let result = validate_base_url_no_ssrf(host);
7989 assert!(
7990 result.is_err(),
7991 "Expected {host} to be rejected (mDNS), got: {:?}",
7992 result
7993 );
7994 }
7995 }
7996
7997 #[test]
7998 fn normalize_base_url_allows_localhost_for_tests() {
7999 assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
8002 assert!(normalize_base_url("http://localhost:8080").is_ok());
8003 }
8004
8005 #[test]
8006 fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
8007 use std::net::IpAddr;
8008 let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
8009
8010 assert!(blocked("10.0.0.1"));
8012 assert!(blocked("192.168.1.1"));
8013 assert!(blocked("169.254.0.1"));
8014 assert!(blocked("100.64.0.1"));
8015 assert!(
8017 blocked("198.18.0.1"),
8018 "RFC2544 benchmark range must be blocked"
8019 );
8020 assert!(blocked("224.0.0.1"), "multicast must be blocked");
8021 assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
8022 assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
8023
8024 assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
8026 assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
8027 assert!(
8028 !blocked("::ffff:127.0.0.1"),
8029 "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
8030 );
8031
8032 assert!(!blocked("8.8.8.8"));
8034 }
8035
8036 #[test]
8043 fn ort_mismatch_message_recommends_auto_fix_first() {
8044 let msg =
8045 format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
8046
8047 assert!(
8049 msg.contains("v1.9.0"),
8050 "should report detected version: {msg}"
8051 );
8052 assert!(
8053 msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
8054 "should report system path: {msg}"
8055 );
8056 assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
8057
8058 let auto_fix_pos = msg
8060 .find("Auto-fix")
8061 .expect("Auto-fix solution missing — users won't discover --fix");
8062 let remove_pos = msg
8063 .find("Remove the old library")
8064 .expect("system-rm solution missing");
8065 assert!(
8066 auto_fix_pos < remove_pos,
8067 "Auto-fix must come before manual rm — see PR comment thread"
8068 );
8069
8070 assert!(
8072 msg.contains("npx @cortexkit/aft doctor --fix"),
8073 "auto-fix command must be present and copy-pasteable: {msg}"
8074 );
8075 }
8076
8077 #[cfg(any(target_os = "linux", target_os = "macos"))]
8078 #[test]
8079 fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
8080 let requested = "libonnxruntime.so";
8081 let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
8082
8083 assert_eq!(detect_ort_version_from_path(requested), None);
8084 let (version, source) =
8085 detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
8086
8087 assert_eq!(version, Some("1.19.0".to_string()));
8088 assert_eq!(source, actual);
8089
8090 let msg = format_ort_version_mismatch(&version.unwrap(), &source);
8091 assert!(msg.contains("v1.19.0"));
8092 assert!(msg.contains(actual));
8093 }
8094
8095 #[test]
8099 fn ort_mismatch_message_handles_macos_dylib_path() {
8100 let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
8101 assert!(msg.contains("v1.9.0"));
8102 assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
8103 assert!(
8107 msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
8108 "system path should be quoted in the auto-fix sentence: {msg}"
8109 );
8110 }
8111
8112 fn fake_managed_ort_tree(storage: &std::path::Path, lib_name: &str, versions: &[(&str, bool)]) {
8118 for (version, has_lib) in versions {
8119 let dir = storage.join("onnxruntime").join(version);
8120 std::fs::create_dir_all(&dir).unwrap();
8121 if *has_lib {
8122 std::fs::write(dir.join(lib_name), b"fake-ort").unwrap();
8123 }
8124 }
8125 }
8126
8127 #[test]
8128 fn managed_ort_resolver_picks_highest_compatible_version() {
8129 let _env_lock = crate::test_env::process_env_lock();
8130 let storage = tempfile::tempdir().unwrap();
8131 fake_managed_ort_tree(
8132 storage.path(),
8133 MANAGED_ORT_LIB_NAME,
8134 &[
8135 ("1.19.0", true), ("1.20.1", true),
8137 ("1.24.4", true), ("1.23.0", true),
8139 ],
8140 );
8141 let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8142 assert_eq!(
8143 found,
8144 storage
8145 .path()
8146 .join("onnxruntime")
8147 .join("1.24.4")
8148 .join(MANAGED_ORT_LIB_NAME)
8149 );
8150 }
8151
8152 #[test]
8153 fn managed_ort_resolver_ignores_non_version_and_pre_120_dirs() {
8154 let _env_lock = crate::test_env::process_env_lock();
8155 let storage = tempfile::tempdir().unwrap();
8156 fake_managed_ort_tree(
8157 storage.path(),
8158 MANAGED_ORT_LIB_NAME,
8159 &[
8160 ("1.19.0", true), ("1.24.4.tmp", true), ("latest", true), ("1.24.4", false), ],
8165 );
8166 assert_eq!(
8167 find_managed_onnx_runtime(storage.path()),
8168 None,
8169 "no compatible version with a library file should resolve"
8170 );
8171 }
8172
8173 #[test]
8174 fn managed_ort_resolver_absent_tree_falls_through() {
8175 let _env_lock = crate::test_env::process_env_lock();
8176 let storage = tempfile::tempdir().unwrap();
8177 assert_eq!(find_managed_onnx_runtime(storage.path()), None);
8179 std::fs::create_dir_all(storage.path().join("onnxruntime")).unwrap();
8181 assert_eq!(find_managed_onnx_runtime(storage.path()), None);
8182 }
8183
8184 #[test]
8185 fn managed_ort_resolver_prefers_version_root_over_lib_subdir() {
8186 let _env_lock = crate::test_env::process_env_lock();
8187 let storage = tempfile::tempdir().unwrap();
8188 let version_dir = storage.path().join("onnxruntime").join("1.24.4");
8189 std::fs::create_dir_all(version_dir.join("lib")).unwrap();
8190 std::fs::write(version_dir.join(MANAGED_ORT_LIB_NAME), b"root").unwrap();
8193 std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
8194 let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8195 assert_eq!(found, version_dir.join(MANAGED_ORT_LIB_NAME));
8196 }
8197
8198 #[test]
8199 fn managed_ort_resolver_accepts_lib_subdir_only() {
8200 let _env_lock = crate::test_env::process_env_lock();
8201 let storage = tempfile::tempdir().unwrap();
8202 let version_dir = storage.path().join("onnxruntime").join("1.24.4");
8203 std::fs::create_dir_all(version_dir.join("lib")).unwrap();
8204 std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
8206 let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8207 assert_eq!(found, version_dir.join("lib").join(MANAGED_ORT_LIB_NAME));
8208 }
8209
8210 #[test]
8211 fn managed_ort_resolver_pre_set_env_short_circuits_without_reading_tree() {
8212 let _env_lock = crate::test_env::process_env_lock();
8213 let storage = tempfile::tempdir().unwrap();
8214 let poison = storage.path().join("onnxruntime").join("1.24.4");
8219 std::fs::create_dir_all(poison.join(MANAGED_ORT_LIB_NAME)).unwrap();
8220
8221 let before = MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed);
8222 std::env::set_var("ORT_DYLIB_PATH", "/explicit/override/libonnxruntime.so");
8224 resolve_managed_onnx_runtime(storage.path());
8225 std::env::remove_var("ORT_DYLIB_PATH");
8226 assert_eq!(
8227 MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed),
8228 before,
8229 "resolver must not read the storage tree when ORT_DYLIB_PATH is pre-set"
8230 );
8231 }
8232
8233 #[test]
8234 fn cancelled_build_stops_before_the_next_embed_batch() {
8235 let project = tempfile::tempdir().expect("project directory");
8236 let files = (0..16)
8237 .map(|index| {
8238 let path = project.path().join(format!("batch_{index}.rs"));
8239 std::fs::write(&path, format!("pub fn batch_symbol_{index}() {{}}\n"))
8240 .expect("write source");
8241 path
8242 })
8243 .collect::<Vec<_>>();
8244 let cancelled = std::sync::atomic::AtomicBool::new(false);
8245 let embed_calls = AtomicUsize::new(0);
8246 let total_chunks = AtomicUsize::new(0);
8247 let mut embed = |texts: Vec<String>| {
8248 let call = embed_calls.fetch_add(1, Ordering::SeqCst) + 1;
8249 assert_eq!(texts.len(), 1, "one chunk per mocked batch");
8250 if call == 1 {
8251 cancelled.store(true, Ordering::SeqCst);
8252 }
8253 Ok(vec![vec![1.0, 2.0, 3.0]])
8254 };
8255 let mut progress = |done: usize, total: usize| {
8256 assert!(done <= total);
8257 total_chunks.store(total, Ordering::SeqCst);
8258 };
8259 let mut should_continue = || !cancelled.load(Ordering::SeqCst);
8260
8261 let error = SemanticIndex::build_with_progress_and_cancellation(
8262 project.path(),
8263 &files,
8264 &mut embed,
8265 1,
8266 &mut progress,
8267 &mut should_continue,
8268 )
8269 .expect_err("the second batch boundary observes cancellation");
8270
8271 let total_chunks = total_chunks.load(Ordering::SeqCst);
8272 assert!(error.contains("semantic build superseded"));
8273 assert_eq!(embed_calls.load(Ordering::SeqCst), 1);
8274 assert!(
8275 total_chunks > 4,
8276 "fixture must contain enough chunks to demonstrate an early stop, got {total_chunks}"
8277 );
8278 }
8279
8280 #[test]
8281 fn managed_ort_resolver_sets_env_when_found() {
8282 let _env_lock = crate::test_env::process_env_lock();
8283 let storage = tempfile::tempdir().unwrap();
8284 fake_managed_ort_tree(storage.path(), MANAGED_ORT_LIB_NAME, &[("1.24.4", true)]);
8285 std::env::remove_var("ORT_DYLIB_PATH");
8286 resolve_managed_onnx_runtime(storage.path());
8287 let set = std::env::var_os("ORT_DYLIB_PATH").expect("resolver sets ORT_DYLIB_PATH");
8288 assert_eq!(
8289 PathBuf::from(set),
8290 storage
8291 .path()
8292 .join("onnxruntime")
8293 .join("1.24.4")
8294 .join(MANAGED_ORT_LIB_NAME)
8295 );
8296 std::env::remove_var("ORT_DYLIB_PATH");
8297 }
8298}