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