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