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