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}
1634
1635#[derive(Debug, Clone, Copy)]
1636struct IndexedFileMetadata {
1637 mtime: SystemTime,
1638 size: u64,
1639 content_hash: blake3::Hash,
1640}
1641
1642#[derive(Debug, Default, Clone, Copy)]
1643struct SemanticCollectPhaseTimings {
1644 sched: Duration,
1645 read_hash: Duration,
1646 parse: Duration,
1647 extract: Duration,
1648 build: Duration,
1649}
1650
1651impl SemanticCollectPhaseTimings {
1652 fn add_assign(&mut self, other: Self) {
1653 self.sched += other.sched;
1654 self.read_hash += other.read_hash;
1655 self.parse += other.parse;
1656 self.extract += other.extract;
1657 self.build += other.build;
1658 }
1659}
1660
1661type CollectedSemanticFile = (
1662 PathBuf,
1663 Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
1664 SemanticCollectPhaseTimings,
1665);
1666
1667#[derive(Debug, Default, Clone, Copy)]
1670pub struct RefreshSummary {
1671 pub changed: usize,
1672 pub added: usize,
1673 pub deleted: usize,
1674 pub total_processed: usize,
1675}
1676
1677impl RefreshSummary {
1678 pub fn is_noop(&self) -> bool {
1680 self.changed == 0 && self.added == 0 && self.deleted == 0
1681 }
1682}
1683
1684#[derive(Debug, Default)]
1685pub struct InvalidatedFilesRefresh {
1686 pub added_entries: Vec<EmbeddingEntry>,
1690 pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
1691 pub completed_paths: Vec<PathBuf>,
1692 pub summary: RefreshSummary,
1693}
1694
1695#[derive(Debug, Clone)]
1696struct ReusableEmbedding {
1697 embed_text: String,
1698 vector: Vec<f32>,
1699}
1700
1701type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
1702
1703#[derive(Debug, Clone)]
1705pub struct SemanticResult {
1706 pub file: PathBuf,
1707 pub name: String,
1708 pub qualified_name: Option<String>,
1709 pub kind: SymbolKind,
1710 pub start_line: u32,
1711 pub end_line: u32,
1712 pub exported: bool,
1713 pub snippet: String,
1714 pub score: f32,
1715 pub rank_score: f32,
1716 pub cap_protected: bool,
1717 pub source: &'static str,
1718}
1719
1720fn relativize_semantic_map<T>(
1721 project_root: &Path,
1722 map: HashMap<PathBuf, T>,
1723) -> Option<HashMap<PathBuf, T>> {
1724 map.into_iter()
1725 .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
1726 .collect()
1727}
1728
1729impl SemanticIndex {
1730 fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
1731 debug_assert!(project_root.is_absolute());
1732 Self {
1733 entries: Vec::new(),
1734 file_mtimes: HashMap::new(),
1735 file_sizes: HashMap::new(),
1736 any_missing_sizes: false,
1737 file_hashes: HashMap::new(),
1738 dimension: shared_base.dimension,
1739 fingerprint: shared_base.fingerprint.clone(),
1740 project_root,
1741 deferred_files: HashSet::new(),
1742 shared_base: Some(shared_base),
1743 }
1744 }
1745
1746 fn into_shared_base(mut self) -> Option<SharedSemanticBase> {
1747 for entry in &mut self.entries {
1748 entry.chunk.file = cache_relative_path(&self.project_root, &entry.chunk.file)?;
1749 }
1750 let deferred_files = self
1751 .deferred_files
1752 .into_iter()
1753 .map(|path| cache_relative_path(&self.project_root, &path))
1754 .collect::<Option<HashSet<_>>>()?;
1755 Some(SharedSemanticBase {
1756 entries: self.entries,
1757 file_mtimes: relativize_semantic_map(&self.project_root, self.file_mtimes)?,
1758 file_sizes: relativize_semantic_map(&self.project_root, self.file_sizes)?,
1759 any_missing_sizes: self.any_missing_sizes,
1760 file_hashes: relativize_semantic_map(&self.project_root, self.file_hashes)?,
1761 dimension: self.dimension,
1762 fingerprint: self.fingerprint,
1763 deferred_files,
1764 })
1765 }
1766
1767 fn materialize_shared_base(&mut self) {
1768 let Some(base) = self.shared_base.take() else {
1769 return;
1770 };
1771 self.entries = base
1772 .entries
1773 .iter()
1774 .cloned()
1775 .map(|mut entry| {
1776 entry.chunk.file = self.project_root.join(&entry.chunk.file);
1777 entry
1778 })
1779 .collect();
1780 self.file_mtimes = base
1781 .file_mtimes
1782 .iter()
1783 .map(|(path, value)| (self.project_root.join(path), *value))
1784 .collect();
1785 self.file_sizes = base
1786 .file_sizes
1787 .iter()
1788 .map(|(path, value)| (self.project_root.join(path), *value))
1789 .collect();
1790 self.any_missing_sizes = base.any_missing_sizes;
1791 self.file_hashes = base
1792 .file_hashes
1793 .iter()
1794 .map(|(path, value)| (self.project_root.join(path), *value))
1795 .collect();
1796 self.dimension = base.dimension;
1797 self.fingerprint = base.fingerprint.clone();
1798 self.deferred_files = base
1799 .deferred_files
1800 .iter()
1801 .map(|path| self.project_root.join(path))
1802 .collect();
1803 }
1804
1805 pub fn new(project_root: PathBuf, dimension: usize) -> Self {
1806 debug_assert!(project_root.is_absolute());
1807 Self {
1808 entries: Vec::new(),
1809 file_mtimes: HashMap::new(),
1810 file_sizes: HashMap::new(),
1811 any_missing_sizes: false,
1812 file_hashes: HashMap::new(),
1813 dimension,
1814 fingerprint: None,
1815 project_root,
1816 deferred_files: HashSet::new(),
1817 shared_base: None,
1818 }
1819 }
1820
1821 pub fn entry_count(&self) -> usize {
1823 self.shared_base
1824 .as_ref()
1825 .map(|base| base.entries.len())
1826 .unwrap_or_else(|| self.entries.len())
1827 }
1828
1829 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1833 if let Some(base) = &self.shared_base {
1834 return crate::memory::MemoryEstimate::estimated(0)
1835 .count("entries", base.entries.len())
1836 .count("dimensions", base.dimension)
1837 .count("indexed_files", base.file_mtimes.len())
1838 .count("shared_base_entries", base.entries.len())
1839 .count("overlay_entries", 0)
1840 .count_u64("vector_bytes", 0)
1841 .count_u64("text_bytes", 0)
1842 .count_u64("metadata_bytes", 0);
1843 }
1844 if self.entries.is_empty()
1845 && self.file_mtimes.is_empty()
1846 && self.file_sizes.is_empty()
1847 && self.file_hashes.is_empty()
1848 && self.deferred_files.is_empty()
1849 {
1850 return crate::memory::MemoryEstimate::estimated(0)
1851 .count("entries", 0)
1852 .count("dimensions", self.dimension)
1853 .count("indexed_files", 0)
1854 .count_u64("vector_bytes", 0)
1855 .count_u64("text_bytes", 0)
1856 .count_u64("metadata_bytes", 0)
1857 .count_u64("average_text_bytes", 0)
1858 .count_u64("average_metadata_bytes", 0);
1859 }
1860 let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1861 bytes.saturating_add(
1862 crate::memory::usize_to_u64(entry.vector.len())
1863 .saturating_mul(std::mem::size_of::<f32>() as u64),
1864 )
1865 });
1866 let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1867 let chunk = &entry.chunk;
1868 bytes
1869 .saturating_add(crate::memory::path_bytes(&chunk.file))
1870 .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
1871 .saturating_add(
1872 chunk
1873 .qualified_name
1874 .as_ref()
1875 .map(|name| crate::memory::usize_to_u64(name.len()))
1876 .unwrap_or(0),
1877 )
1878 .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
1879 .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
1880 });
1881 let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
1882 .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
1883 let file_metadata_bytes = self
1884 .file_mtimes
1885 .keys()
1886 .chain(self.file_sizes.keys())
1887 .chain(self.file_hashes.keys())
1888 .chain(self.deferred_files.iter())
1889 .map(|path| crate::memory::path_bytes(path))
1890 .fold(0u64, u64::saturating_add)
1891 .saturating_add(
1892 crate::memory::usize_to_u64(self.file_mtimes.len())
1893 .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
1894 )
1895 .saturating_add(
1896 crate::memory::usize_to_u64(self.file_sizes.len())
1897 .saturating_mul(std::mem::size_of::<u64>() as u64),
1898 )
1899 .saturating_add(
1900 crate::memory::usize_to_u64(self.file_hashes.len())
1901 .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
1902 );
1903 let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
1904 self.fingerprint
1905 .as_ref()
1906 .map(|fingerprint| {
1907 crate::memory::usize_to_u64(fingerprint.backend.len())
1908 .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
1909 .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
1910 })
1911 .unwrap_or(0),
1912 );
1913 let metadata_bytes = entry_metadata_bytes
1914 .saturating_add(file_metadata_bytes)
1915 .saturating_add(index_metadata_bytes);
1916 let entry_count = crate::memory::usize_to_u64(self.entries.len());
1917 crate::memory::MemoryEstimate::estimated(
1918 vector_bytes
1919 .saturating_add(text_bytes)
1920 .saturating_add(metadata_bytes),
1921 )
1922 .count("entries", self.entries.len())
1923 .count("dimensions", self.dimension)
1924 .count("indexed_files", self.file_mtimes.len())
1925 .count_u64("vector_bytes", vector_bytes)
1926 .count_u64("text_bytes", text_bytes)
1927 .count_u64("metadata_bytes", metadata_bytes)
1928 .count_u64(
1929 "average_text_bytes",
1930 text_bytes.checked_div(entry_count).unwrap_or(0),
1931 )
1932 .count_u64(
1933 "average_metadata_bytes",
1934 metadata_bytes.checked_div(entry_count).unwrap_or(0),
1935 )
1936 }
1937
1938 pub fn indexed_file_count(&self) -> usize {
1940 self.shared_base
1941 .as_ref()
1942 .map(|base| base.file_mtimes.len())
1943 .unwrap_or_else(|| self.file_mtimes.len())
1944 }
1945
1946 pub fn status_label(&self) -> &'static str {
1948 if self.entry_count() == 0 {
1949 "empty"
1950 } else {
1951 "ready"
1952 }
1953 }
1954
1955 fn collect_chunks(
1956 project_root: &Path,
1957 files: &[PathBuf],
1958 ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
1959 let collect_started = Instant::now();
1960 let collect_one = |file: &Path, sched: Duration| {
1961 let mut phases = SemanticCollectPhaseTimings {
1962 sched,
1963 ..SemanticCollectPhaseTimings::default()
1964 };
1965 let result = collect_semantic_file(project_root, file, &mut phases);
1966 (file.to_path_buf(), result, phases)
1967 };
1968 let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
1969 files
1970 .iter()
1971 .map(|file| collect_one(file, Duration::ZERO))
1972 .collect()
1973 } else {
1974 files
1975 .par_iter()
1976 .map(|file| collect_one(file, collect_started.elapsed()))
1977 .collect()
1978 };
1979
1980 let mut chunks: Vec<SemanticChunk> = Vec::new();
1981 let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
1982 let mut phases = SemanticCollectPhaseTimings::default();
1983
1984 for (file, result, file_phases) in per_file {
1985 phases.add_assign(file_phases);
1986 match result {
1987 Ok((metadata, file_chunks)) => {
1988 file_metadata.insert(file, metadata);
1989 chunks.extend(file_chunks);
1990 }
1991 Err(error) => {
1992 if error == "unsupported file extension" {
1998 continue;
1999 }
2000 slog_warn!(
2001 "failed to collect semantic chunks for {}: {}",
2002 file.display(),
2003 error
2004 );
2005 }
2006 }
2007 }
2008
2009 let collect_ms = collect_started
2010 .elapsed()
2011 .as_millis()
2012 .min(u128::from(u64::MAX)) as u64;
2013 crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
2014 slog_info!(
2015 "semantic collect: {} chunks from {} files in {} ms",
2016 chunks.len(),
2017 file_metadata.len(),
2018 collect_ms
2019 );
2020 if collect_ms > 50 {
2021 slog_info!(
2022 "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
2023 phases.sched.as_millis(),
2024 phases.read_hash.as_millis(),
2025 phases.parse.as_millis(),
2026 phases.extract.as_millis(),
2027 phases.build.as_millis(),
2028 );
2029 }
2030
2031 (chunks, file_metadata)
2032 }
2033
2034 fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
2035 let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2036 let mut reuse_map: ChunkReuseMap = HashMap::new();
2037
2038 for entry in &self.entries {
2039 if !requested.contains(entry.chunk.file.as_path()) {
2040 continue;
2041 }
2042
2043 let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
2048 reuse_map
2049 .entry(entry.chunk.file.clone())
2050 .or_default()
2051 .entry(hash)
2052 .or_default()
2053 .push(ReusableEmbedding {
2054 embed_text: entry.chunk.embed_text.clone(),
2055 vector: entry.vector.clone(),
2056 });
2057 }
2058
2059 reuse_map
2060 }
2061
2062 fn reusable_vector_for_chunk(
2063 reuse_map: &ChunkReuseMap,
2064 chunk: &SemanticChunk,
2065 ) -> Option<Vec<f32>> {
2066 let hash = blake3::hash(chunk.embed_text.as_bytes());
2067 reuse_map
2068 .get(&chunk.file)?
2069 .get(&hash)?
2070 .iter()
2071 .find(|candidate| candidate.embed_text == chunk.embed_text)
2072 .map(|candidate| candidate.vector.clone())
2073 }
2074
2075 fn entries_for_chunks_with_reuse<F, P>(
2076 chunks: Vec<SemanticChunk>,
2077 reuse_map: &ChunkReuseMap,
2078 embed_fn: &mut F,
2079 max_batch_size: usize,
2080 initial_observed_dimension: Option<usize>,
2081 refresh_label: &str,
2082 progress: &mut P,
2083 ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
2084 where
2085 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2086 P: FnMut(usize, usize),
2087 {
2088 let total_chunks = chunks.len();
2089 progress(0, total_chunks);
2090
2091 let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
2092 let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
2093
2094 for (chunk_index, chunk) in chunks.into_iter().enumerate() {
2095 if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
2096 entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2097 } else {
2098 misses.push((chunk_index, chunk));
2099 }
2100 }
2101
2102 let mut completed = total_chunks.saturating_sub(misses.len());
2103 if completed > 0 {
2104 progress(completed, total_chunks);
2105 }
2106
2107 let batch_size = max_batch_size.max(1);
2108 let mut observed_dimension = initial_observed_dimension;
2109
2110 for batch_start in (0..misses.len()).step_by(batch_size) {
2111 let batch_end = (batch_start + batch_size).min(misses.len());
2112 let batch_texts: Vec<String> = misses[batch_start..batch_end]
2113 .iter()
2114 .map(|(_, chunk)| chunk.embed_text.clone())
2115 .collect();
2116
2117 let vectors = embed_fn(batch_texts)?;
2118 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2119
2120 if let Some(dim) = vectors.first().map(|vector| vector.len()) {
2121 match observed_dimension {
2122 None => observed_dimension = Some(dim),
2123 Some(expected) if dim != expected => {
2124 return Err(format!(
2125 "embedding dimension changed during {refresh_label}: \
2126 cached index uses {expected}, new vectors use {dim}"
2127 ));
2128 }
2129 _ => {}
2130 }
2131 }
2132
2133 for (i, vector) in vectors.into_iter().enumerate() {
2134 let (chunk_index, chunk) = misses[batch_start + i].clone();
2135 entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2136 }
2137
2138 completed += batch_end - batch_start;
2139 progress(completed, total_chunks);
2140 }
2141
2142 let entries = entries_by_chunk
2143 .into_iter()
2144 .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
2145 .collect();
2146
2147 Ok((entries, observed_dimension))
2148 }
2149
2150 fn build_from_chunks<F, P>(
2151 project_root: &Path,
2152 chunks: Vec<SemanticChunk>,
2153 file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
2154 embed_fn: &mut F,
2155 max_batch_size: usize,
2156 mut progress: Option<&mut P>,
2157 ) -> Result<Self, String>
2158 where
2159 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2160 P: FnMut(usize, usize),
2161 {
2162 debug_assert!(project_root.is_absolute());
2163 let total_chunks = chunks.len();
2164
2165 if chunks.is_empty() {
2166 return Ok(Self {
2167 entries: Vec::new(),
2168 file_mtimes: file_metadata
2169 .iter()
2170 .map(|(path, metadata)| (path.clone(), metadata.mtime))
2171 .collect(),
2172 file_sizes: file_metadata
2173 .iter()
2174 .map(|(path, metadata)| (path.clone(), metadata.size))
2175 .collect(),
2176 any_missing_sizes: false,
2177 file_hashes: file_metadata
2178 .into_iter()
2179 .map(|(path, metadata)| (path, metadata.content_hash))
2180 .collect(),
2181 dimension: DEFAULT_DIMENSION,
2182 fingerprint: None,
2183 project_root: project_root.to_path_buf(),
2184 deferred_files: HashSet::new(),
2185 shared_base: None,
2186 });
2187 }
2188
2189 let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
2191 let mut expected_dimension: Option<usize> = None;
2192 let batch_size = max_batch_size.max(1);
2193 let embed_started = std::time::Instant::now();
2194 let batch_count = total_chunks.div_ceil(batch_size);
2195 for batch_start in (0..chunks.len()).step_by(batch_size) {
2196 let batch_end = (batch_start + batch_size).min(chunks.len());
2197 let batch_texts: Vec<String> = chunks[batch_start..batch_end]
2198 .iter()
2199 .map(|c| c.embed_text.clone())
2200 .collect();
2201
2202 let vectors = embed_fn(batch_texts)?;
2203 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2204
2205 if let Some(dim) = vectors.first().map(|v| v.len()) {
2207 match expected_dimension {
2208 None => expected_dimension = Some(dim),
2209 Some(expected) if dim != expected => {
2210 return Err(format!(
2211 "embedding dimension changed across batches: expected {expected}, got {dim}"
2212 ));
2213 }
2214 _ => {}
2215 }
2216 }
2217
2218 for (i, vector) in vectors.into_iter().enumerate() {
2219 let chunk_idx = batch_start + i;
2220 entries.push(EmbeddingEntry::new(chunks[chunk_idx].clone(), vector));
2221 }
2222
2223 if let Some(callback) = progress.as_mut() {
2224 callback(entries.len(), total_chunks);
2225 }
2226 }
2227
2228 let embed_ms = embed_started.elapsed().as_millis();
2229 let rate = (total_chunks as u128 * 1000)
2230 .checked_div(embed_ms)
2231 .unwrap_or(0) as u64;
2232 slog_info!(
2233 "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
2234 total_chunks,
2235 batch_count,
2236 embed_ms,
2237 rate
2238 );
2239
2240 let dimension = entries
2241 .first()
2242 .map(|e| e.vector.len())
2243 .unwrap_or(DEFAULT_DIMENSION);
2244
2245 Ok(Self {
2246 entries,
2247 file_mtimes: file_metadata
2248 .iter()
2249 .map(|(path, metadata)| (path.clone(), metadata.mtime))
2250 .collect(),
2251 file_sizes: file_metadata
2252 .iter()
2253 .map(|(path, metadata)| (path.clone(), metadata.size))
2254 .collect(),
2255 any_missing_sizes: false,
2256 file_hashes: file_metadata
2257 .into_iter()
2258 .map(|(path, metadata)| (path, metadata.content_hash))
2259 .collect(),
2260 dimension,
2261 fingerprint: None,
2262 project_root: project_root.to_path_buf(),
2263 deferred_files: HashSet::new(),
2264 shared_base: None,
2265 })
2266 }
2267
2268 pub fn build<F>(
2271 project_root: &Path,
2272 files: &[PathBuf],
2273 embed_fn: &mut F,
2274 max_batch_size: usize,
2275 ) -> Result<Self, String>
2276 where
2277 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2278 {
2279 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2280 Self::build_from_chunks(
2281 project_root,
2282 chunks,
2283 file_mtimes,
2284 embed_fn,
2285 max_batch_size,
2286 Option::<&mut fn(usize, usize)>::None,
2287 )
2288 }
2289
2290 pub fn build_with_progress<F, P>(
2292 project_root: &Path,
2293 files: &[PathBuf],
2294 embed_fn: &mut F,
2295 max_batch_size: usize,
2296 progress: &mut P,
2297 ) -> Result<Self, String>
2298 where
2299 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2300 P: FnMut(usize, usize),
2301 {
2302 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2303 let total_chunks = chunks.len();
2304 progress(0, total_chunks);
2305 Self::build_from_chunks(
2306 project_root,
2307 chunks,
2308 file_mtimes,
2309 embed_fn,
2310 max_batch_size,
2311 Some(progress),
2312 )
2313 }
2314
2315 pub fn refresh_stale_files<F, P>(
2326 &mut self,
2327 project_root: &Path,
2328 current_files: &[PathBuf],
2329 embed_fn: &mut F,
2330 max_batch_size: usize,
2331 progress: &mut P,
2332 ) -> Result<RefreshSummary, String>
2333 where
2334 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2335 P: FnMut(usize, usize),
2336 {
2337 self.refresh_stale_files_with_strategy(
2338 project_root,
2339 current_files,
2340 embed_fn,
2341 max_batch_size,
2342 progress,
2343 cache_freshness::VerifyStrategy::Strict,
2344 )
2345 }
2346
2347 pub(crate) fn refresh_stale_files_with_strategy<F, P>(
2348 &mut self,
2349 project_root: &Path,
2350 current_files: &[PathBuf],
2351 embed_fn: &mut F,
2352 max_batch_size: usize,
2353 progress: &mut P,
2354 verify_strategy: cache_freshness::VerifyStrategy,
2355 ) -> Result<RefreshSummary, String>
2356 where
2357 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2358 P: FnMut(usize, usize),
2359 {
2360 self.materialize_shared_base();
2361 self.backfill_missing_file_sizes();
2362
2363 let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
2365 self.deferred_files
2366 .retain(|path| current_set.contains(path.as_path()));
2367 let total_processed = current_set.len() + self.file_mtimes.len()
2368 - self
2369 .file_mtimes
2370 .keys()
2371 .filter(|path| current_set.contains(path.as_path()))
2372 .count();
2373
2374 enum IndexedFileCheck {
2377 Deleted(PathBuf),
2378 MissingMetadata(PathBuf),
2379 Verified(PathBuf, FreshnessVerdict),
2380 }
2381
2382 let mut deleted: Vec<PathBuf> = Vec::new();
2383 let mut changed: Vec<PathBuf> = Vec::new();
2384 let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
2385 let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
2386 let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
2387
2388 for indexed_path in indexed_paths {
2389 let check_index = checks.len();
2390 if !current_set.contains(indexed_path.as_path()) {
2391 checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
2392 continue;
2393 }
2394 let cached = match (
2395 self.file_mtimes.get(&indexed_path),
2396 self.file_sizes.get(&indexed_path),
2397 self.file_hashes.get(&indexed_path),
2398 ) {
2399 (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
2400 mtime: *mtime,
2401 size: *size,
2402 content_hash: *hash,
2403 }),
2404 _ => None,
2405 };
2406 if let Some(freshness) = cached {
2407 strict_verify_inputs.push((check_index, indexed_path, freshness));
2408 checks.push(None);
2409 } else {
2410 checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
2411 }
2412 }
2413
2414 let verified = match verify_strategy {
2415 cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
2416 strict_verify_inputs,
2417 cache_freshness::VerifyStrategy::StatFirst,
2418 ),
2419 cache_freshness::VerifyStrategy::Strict => {
2420 cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
2421 }
2422 };
2423 for (check_index, path, verdict) in verified {
2424 checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
2425 }
2426
2427 for check in checks {
2428 match check.expect("freshness check should be populated") {
2429 IndexedFileCheck::Deleted(path) => deleted.push(path),
2430 IndexedFileCheck::MissingMetadata(path) => changed.push(path),
2431 IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
2432 IndexedFileCheck::Verified(
2433 path,
2434 FreshnessVerdict::ContentFresh {
2435 new_mtime,
2436 new_size,
2437 },
2438 ) => {
2439 self.file_mtimes.insert(path.clone(), new_mtime);
2440 self.file_sizes.insert(path, new_size);
2441 }
2442 IndexedFileCheck::Verified(
2443 path,
2444 FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
2445 ) => {
2446 changed.push(path);
2447 }
2448 }
2449 }
2450
2451 let mut added: Vec<PathBuf> = Vec::new();
2453 for path in current_files {
2454 if !self.file_mtimes.contains_key(path) {
2455 added.push(path.clone());
2456 }
2457 }
2458
2459 if deleted.is_empty() && changed.is_empty() && added.is_empty() {
2461 progress(0, 0);
2462 return Ok(RefreshSummary {
2463 total_processed,
2464 ..RefreshSummary::default()
2465 });
2466 }
2467
2468 if !deleted.is_empty() {
2472 self.remove_indexed_files(&deleted);
2473 }
2474
2475 let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
2477 to_embed.extend(changed.iter().cloned());
2478 to_embed.extend(added.iter().cloned());
2479
2480 if to_embed.is_empty() {
2481 progress(0, 0);
2483 return Ok(RefreshSummary {
2484 changed: 0,
2485 added: 0,
2486 deleted: deleted.len(),
2487 total_processed,
2488 });
2489 }
2490
2491 let reuse_map = self.build_chunk_reuse_map(&changed);
2492 let (chunks, fresh_metadata) = Self::collect_chunks(project_root, &to_embed);
2493 let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
2494 let vanished = to_embed
2495 .iter()
2496 .filter(|path| {
2497 changed_set.contains(path.as_path())
2498 && !fresh_metadata.contains_key(*path)
2499 && !path.exists()
2500 })
2501 .cloned()
2502 .collect::<Vec<_>>();
2503 if !vanished.is_empty() {
2504 self.remove_indexed_files(&vanished);
2505 deleted.extend(vanished);
2506 }
2507
2508 if chunks.is_empty() {
2509 progress(0, 0);
2510 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2511 for file in &successful_files {
2512 self.deferred_files.remove(file);
2513 }
2514 if !successful_files.is_empty() {
2515 self.entries
2516 .retain(|entry| !successful_files.contains(&entry.chunk.file));
2517 }
2518 let changed_count = changed
2519 .iter()
2520 .filter(|path| successful_files.contains(*path))
2521 .count();
2522 let added_count = added
2523 .iter()
2524 .filter(|path| successful_files.contains(*path))
2525 .count();
2526 for (file, metadata) in fresh_metadata {
2527 self.file_mtimes.insert(file.clone(), metadata.mtime);
2528 self.file_sizes.insert(file.clone(), metadata.size);
2529 self.file_hashes.insert(file.clone(), metadata.content_hash);
2530 }
2531 return Ok(RefreshSummary {
2532 changed: changed_count,
2533 added: added_count,
2534 deleted: deleted.len(),
2535 total_processed,
2536 });
2537 }
2538
2539 let existing_dimension = if self.entries.is_empty() {
2542 None
2543 } else {
2544 Some(self.dimension)
2545 };
2546 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
2547 chunks,
2548 &reuse_map,
2549 embed_fn,
2550 max_batch_size,
2551 existing_dimension,
2552 "incremental refresh",
2553 progress,
2554 )?;
2555
2556 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2557 for file in &successful_files {
2558 self.deferred_files.remove(file);
2559 }
2560 if !successful_files.is_empty() {
2561 self.entries
2562 .retain(|entry| !successful_files.contains(&entry.chunk.file));
2563 }
2564
2565 self.entries.extend(new_entries);
2566 for (file, metadata) in fresh_metadata {
2567 self.file_mtimes.insert(file.clone(), metadata.mtime);
2568 self.file_sizes.insert(file.clone(), metadata.size);
2569 self.file_hashes.insert(file, metadata.content_hash);
2570 }
2571 if let Some(dim) = observed_dimension {
2572 self.dimension = dim;
2573 }
2574
2575 Ok(RefreshSummary {
2576 changed: changed
2577 .iter()
2578 .filter(|path| successful_files.contains(*path))
2579 .count(),
2580 added: added
2581 .iter()
2582 .filter(|path| successful_files.contains(*path))
2583 .count(),
2584 deleted: deleted.len(),
2585 total_processed,
2586 })
2587 }
2588
2589 pub fn refresh_invalidated_files<F, P>(
2596 &mut self,
2597 project_root: &Path,
2598 paths: &[PathBuf],
2599 embed_fn: &mut F,
2600 max_batch_size: usize,
2601 max_files: usize,
2602 progress: &mut P,
2603 ) -> Result<InvalidatedFilesRefresh, String>
2604 where
2605 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2606 P: FnMut(usize, usize),
2607 {
2608 self.materialize_shared_base();
2609 self.backfill_missing_file_sizes();
2610
2611 self.deferred_files.retain(|path| path.exists());
2612 let mut requested_paths = paths.to_vec();
2613 requested_paths.extend(self.deferred_files.iter().cloned());
2614 requested_paths.sort();
2615 requested_paths.dedup();
2616 let total_processed = requested_paths.len();
2617
2618 if requested_paths.is_empty() {
2619 progress(0, 0);
2620 return Ok(InvalidatedFilesRefresh {
2621 summary: RefreshSummary {
2622 total_processed,
2623 ..RefreshSummary::default()
2624 },
2625 ..InvalidatedFilesRefresh::default()
2626 });
2627 }
2628
2629 let previously_indexed: HashSet<PathBuf> = requested_paths
2630 .iter()
2631 .filter(|path| self.file_mtimes.contains_key(*path))
2632 .cloned()
2633 .collect();
2634 let reuse_map = self.build_chunk_reuse_map(&requested_paths);
2635
2636 self.remove_indexed_files(&requested_paths);
2640
2641 let existing_paths = requested_paths
2642 .iter()
2643 .filter(|path| path.exists())
2644 .cloned()
2645 .collect::<Vec<_>>();
2646 let deleted = requested_paths
2647 .iter()
2648 .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
2649 .count();
2650
2651 if existing_paths.is_empty() {
2652 for path in &requested_paths {
2653 if !path.exists() {
2654 self.deferred_files.remove(path);
2655 }
2656 }
2657 progress(0, 0);
2658 return Ok(InvalidatedFilesRefresh {
2659 completed_paths: requested_paths,
2660 summary: RefreshSummary {
2661 deleted,
2662 total_processed,
2663 ..RefreshSummary::default()
2664 },
2665 ..InvalidatedFilesRefresh::default()
2666 });
2667 }
2668
2669 let (mut chunks, mut fresh_metadata) = Self::collect_chunks(project_root, &existing_paths);
2670
2671 let retained_file_count = self.file_mtimes.len();
2672 let changed_successful_count = existing_paths
2673 .iter()
2674 .filter(|path| {
2675 previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2676 })
2677 .count();
2678 let available_new_files =
2679 max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
2680 let new_successful_files = existing_paths
2681 .iter()
2682 .filter(|path| {
2683 !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2684 })
2685 .cloned()
2686 .collect::<Vec<_>>();
2687 if new_successful_files.len() > available_new_files {
2688 let allowed_new_files = new_successful_files
2689 .iter()
2690 .take(available_new_files)
2691 .cloned()
2692 .collect::<HashSet<_>>();
2693 let deferred_new_files = new_successful_files
2694 .into_iter()
2695 .filter(|path| !allowed_new_files.contains(path))
2696 .collect::<HashSet<_>>();
2697
2698 fresh_metadata.retain(|file, _| {
2699 previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
2700 });
2701 chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
2702
2703 if !deferred_new_files.is_empty() {
2704 for path in &deferred_new_files {
2705 self.deferred_files.insert(path.clone());
2706 }
2707 slog_warn!(
2708 "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
2709 deferred_new_files.len(),
2710 max_files
2711 );
2712 }
2713 }
2714
2715 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2716 for file in &successful_files {
2717 self.deferred_files.remove(file);
2718 }
2719 let changed = successful_files
2720 .iter()
2721 .filter(|path| previously_indexed.contains(path.as_path()))
2722 .count();
2723 let added = successful_files.len().saturating_sub(changed);
2724 let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
2725
2726 if chunks.is_empty() {
2727 progress(0, 0);
2728 for (file, metadata) in fresh_metadata {
2729 let freshness = FileFreshness {
2730 mtime: metadata.mtime,
2731 size: metadata.size,
2732 content_hash: metadata.content_hash,
2733 };
2734 self.file_mtimes.insert(file.clone(), freshness.mtime);
2735 self.file_sizes.insert(file.clone(), freshness.size);
2736 self.file_hashes
2737 .insert(file.clone(), freshness.content_hash);
2738 updated_metadata.push((file, freshness));
2739 }
2740
2741 return Ok(InvalidatedFilesRefresh {
2742 updated_metadata,
2743 completed_paths: requested_paths,
2744 summary: RefreshSummary {
2745 changed,
2746 added,
2747 deleted,
2748 total_processed,
2749 },
2750 ..InvalidatedFilesRefresh::default()
2751 });
2752 }
2753
2754 let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
2755 {
2756 None
2757 } else {
2758 Some(self.dimension)
2759 };
2760 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
2761 chunks,
2762 &reuse_map,
2763 embed_fn,
2764 max_batch_size,
2765 initial_observed_dimension,
2766 "invalidated-file refresh",
2767 progress,
2768 )?;
2769
2770 let added_entries = new_entries.clone();
2771 self.entries.extend(new_entries);
2772 for (file, metadata) in fresh_metadata {
2773 let freshness = FileFreshness {
2774 mtime: metadata.mtime,
2775 size: metadata.size,
2776 content_hash: metadata.content_hash,
2777 };
2778 self.file_mtimes.insert(file.clone(), freshness.mtime);
2779 self.file_sizes.insert(file.clone(), freshness.size);
2780 self.file_hashes
2781 .insert(file.clone(), freshness.content_hash);
2782 updated_metadata.push((file, freshness));
2783 }
2784 if let Some(dim) = observed_dimension {
2785 self.dimension = dim;
2786 }
2787
2788 Ok(InvalidatedFilesRefresh {
2789 added_entries,
2790 updated_metadata,
2791 completed_paths: requested_paths,
2792 summary: RefreshSummary {
2793 changed,
2794 added,
2795 deleted,
2796 total_processed,
2797 },
2798 })
2799 }
2800
2801 pub fn apply_refresh_update(
2802 &mut self,
2803 added_entries: Vec<EmbeddingEntry>,
2804 updated_metadata: Vec<(PathBuf, FileFreshness)>,
2805 completed_paths: &[PathBuf],
2806 ) {
2807 self.materialize_shared_base();
2808 self.remove_indexed_files(completed_paths);
2812
2813 let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
2814 self.entries.extend(added_entries);
2815 for (file, freshness) in updated_metadata {
2816 self.file_mtimes.insert(file.clone(), freshness.mtime);
2817 self.file_sizes.insert(file.clone(), freshness.size);
2818 self.file_hashes.insert(file, freshness.content_hash);
2819 }
2820 if let Some(dim) = observed_dimension {
2821 self.dimension = dim;
2822 }
2823 }
2824
2825 fn remove_indexed_files(&mut self, files: &[PathBuf]) {
2826 let deleted_set: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2827 self.entries
2828 .retain(|entry| !deleted_set.contains(entry.chunk.file.as_path()));
2829 for path in files {
2830 self.file_mtimes.remove(path);
2831 self.file_sizes.remove(path);
2832 self.file_hashes.remove(path);
2833 }
2834 }
2835
2836 pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
2838 let (entries, dimension) = self
2839 .shared_base
2840 .as_ref()
2841 .map(|base| (base.entries.as_slice(), base.dimension))
2842 .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
2843 if entries.is_empty() || query_vector.len() != dimension {
2844 return Vec::new();
2845 }
2846
2847 let query_norm = vector_norm(query_vector);
2850 let mut scored: Vec<(f32, usize)> = entries
2851 .iter()
2852 .enumerate()
2853 .map(|(i, entry)| {
2854 let dot = if query_vector.len() == entry.vector.len() {
2855 dot_product(query_vector, &entry.vector)
2856 } else {
2857 0.0
2858 };
2859 let denom = query_norm * entry.norm;
2860 let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
2861 if entry.chunk.exported {
2862 score *= 1.1;
2863 }
2864 (score, i)
2865 })
2866 .collect();
2867
2868 let keep = top_k.min(scored.len());
2869 if keep == 0 {
2870 return Vec::new();
2871 }
2872
2873 if keep < scored.len() {
2874 scored.select_nth_unstable_by(keep, semantic_score_order);
2875 scored.truncate(keep);
2876 }
2877 scored.sort_by(semantic_score_order);
2878
2879 scored
2880 .into_iter()
2881 .map(|(score, idx)| {
2885 let entry = &entries[idx];
2886 SemanticResult {
2887 file: if self.shared_base.is_some() {
2888 self.project_root.join(&entry.chunk.file)
2889 } else {
2890 entry.chunk.file.clone()
2891 },
2892 name: entry.chunk.name.clone(),
2893 qualified_name: entry.chunk.qualified_name.clone(),
2894 kind: entry.chunk.kind.clone(),
2895 start_line: entry.chunk.start_line,
2896 end_line: entry.chunk.end_line,
2897 exported: entry.chunk.exported,
2898 snippet: entry.chunk.snippet.clone(),
2899 score,
2900 rank_score: score,
2901 cap_protected: false,
2902 source: "semantic",
2903 }
2904 })
2905 .collect()
2906 }
2907
2908 pub fn len(&self) -> usize {
2910 self.entry_count()
2911 }
2912
2913 pub fn is_file_stale(&self, file: &Path) -> bool {
2915 let relative;
2916 let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
2917 relative = file
2918 .strip_prefix(&self.project_root)
2919 .unwrap_or(file)
2920 .to_path_buf();
2921 (
2922 &base.file_mtimes,
2923 &base.file_sizes,
2924 &base.file_hashes,
2925 relative.as_path(),
2926 )
2927 } else {
2928 (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
2929 };
2930 let Some(stored_mtime) = file_mtimes.get(lookup) else {
2931 return true;
2932 };
2933 let Some(stored_size) = file_sizes.get(lookup) else {
2934 return true;
2935 };
2936 let Some(stored_hash) = file_hashes.get(lookup) else {
2937 return true;
2938 };
2939 let cached = FileFreshness {
2940 mtime: *stored_mtime,
2941 size: *stored_size,
2942 content_hash: *stored_hash,
2943 };
2944 match cache_freshness::verify_file_strict(file, &cached) {
2945 FreshnessVerdict::HotFresh => false,
2946 FreshnessVerdict::ContentFresh { .. } => false,
2947 FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
2948 }
2949 }
2950
2951 fn backfill_missing_file_sizes(&mut self) {
2952 if !self.any_missing_sizes {
2953 return;
2954 }
2955
2956 for path in self.file_mtimes.keys() {
2957 if self.file_sizes.contains_key(path) {
2958 continue;
2959 }
2960 if let Ok(metadata) = fs::metadata(path) {
2961 self.file_sizes.insert(path.clone(), metadata.len());
2962 if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
2963 self.file_hashes.insert(path.clone(), hash);
2964 }
2965 }
2966 }
2967 self.any_missing_sizes = self
2968 .file_mtimes
2969 .keys()
2970 .any(|path| !self.file_sizes.contains_key(path));
2971 }
2972
2973 pub fn remove_file(&mut self, file: &Path) {
2975 self.invalidate_file(file);
2976 }
2977
2978 pub fn invalidate_file(&mut self, file: &Path) {
2979 self.materialize_shared_base();
2980 let canonical_file = canonicalize_existing_or_deleted_path(file);
2981 self.entries
2982 .retain(|e| e.chunk.file != file && e.chunk.file != canonical_file);
2983 self.file_mtimes.remove(file);
2984 self.file_sizes.remove(file);
2985 self.file_hashes.remove(file);
2986 if canonical_file.as_path() != file {
2987 self.file_mtimes.remove(&canonical_file);
2988 self.file_sizes.remove(&canonical_file);
2989 self.file_hashes.remove(&canonical_file);
2990 }
2991 }
2992
2993 pub fn dimension(&self) -> usize {
2995 self.shared_base
2996 .as_ref()
2997 .map(|base| base.dimension)
2998 .unwrap_or(self.dimension)
2999 }
3000
3001 pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
3002 self.shared_base
3003 .as_ref()
3004 .and_then(|base| base.fingerprint.as_ref())
3005 .or(self.fingerprint.as_ref())
3006 }
3007
3008 pub fn backend_label(&self) -> Option<&str> {
3009 self.fingerprint().map(|f| f.backend.as_str())
3010 }
3011
3012 pub fn model_label(&self) -> Option<&str> {
3013 self.fingerprint().map(|f| f.model.as_str())
3014 }
3015
3016 pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
3017 self.materialize_shared_base();
3018 self.fingerprint = Some(fingerprint);
3019 }
3020
3021 pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
3025 if self.shared_base.is_some() {
3026 let mut private = self.clone();
3027 private.materialize_shared_base();
3028 return private.write_to_disk(storage_dir, project_key);
3029 }
3030 let dir = storage_dir.join("semantic").join(project_key);
3031 let data_path = dir.join("semantic.bin");
3032 let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
3033 if !access.allows_write(project_key, &data_path) {
3034 return false;
3035 }
3036 if let Err(e) = fs::create_dir_all(&dir) {
3037 slog_warn!("failed to create semantic cache dir: {}", e);
3038 return false;
3039 }
3040 let tmp_path = dir.join(format!(
3041 "semantic.bin.tmp.{}.{}",
3042 std::process::id(),
3043 SystemTime::now()
3044 .duration_since(SystemTime::UNIX_EPOCH)
3045 .unwrap_or(Duration::ZERO)
3046 .as_nanos()
3047 ));
3048 let write_result = (|| -> io::Result<usize> {
3049 let file = fs::File::create(&tmp_path)?;
3050 let mut writer = BufWriter::new(file);
3051 let bytes_written = self.write_to_writer(&mut writer)?;
3052 writer.flush()?;
3053 writer.get_ref().sync_all()?;
3054 Ok(bytes_written)
3055 })();
3056 let bytes_written = match write_result {
3057 Ok(bytes_written) => bytes_written,
3058 Err(e) => {
3059 slog_warn!("failed to write semantic index: {}", e);
3060 let _ = fs::remove_file(&tmp_path);
3061 return false;
3062 }
3063 };
3064 if let Err(e) = crate::fs_lock::rename_over(&tmp_path, &data_path) {
3065 slog_warn!("failed to rename semantic index: {}", e);
3066 let _ = fs::remove_file(&tmp_path);
3067 return false;
3068 }
3069 slog_info!(
3070 "semantic index persisted: {} entries, {:.1} KB",
3071 self.entries.len(),
3072 bytes_written as f64 / 1024.0
3073 );
3074 true
3075 }
3076
3077 pub fn read_from_disk(
3079 storage_dir: &Path,
3080 project_key: &str,
3081 current_canonical_root: &Path,
3082 is_worktree_bridge: bool,
3083 expected_fingerprint: Option<&str>,
3084 ) -> Option<Self> {
3085 debug_assert!(current_canonical_root.is_absolute());
3086 let data_path = storage_dir
3087 .join("semantic")
3088 .join(project_key)
3089 .join("semantic.bin");
3090 let file = fs::File::open(&data_path).ok()?;
3091 let file_len = usize::try_from(file.metadata().ok()?.len()).ok()?;
3092 if file_len < HEADER_BYTES_V1 {
3093 slog_warn!(
3094 "corrupt semantic index (too small: {} bytes), removing",
3095 file_len
3096 );
3097 if !is_worktree_bridge {
3098 let _ = fs::remove_file(&data_path);
3099 }
3100 return None;
3101 }
3102
3103 let mut reader = BufReader::new(file);
3104 let mut version_buf = [0u8; 1];
3105 reader.read_exact(&mut version_buf).ok()?;
3106 let version = version_buf[0];
3107 if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
3108 slog_info!(
3109 "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
3110 version,
3111 SEMANTIC_INDEX_VERSION_V7
3112 );
3113 return None;
3114 }
3115 match Self::from_reader_after_version(
3116 reader,
3117 version,
3118 current_canonical_root,
3119 Some(file_len),
3120 1,
3121 ) {
3122 Ok(index) => {
3123 if let Some(expected) = expected_fingerprint {
3124 let matches = index
3125 .fingerprint()
3126 .map(|fingerprint| fingerprint.matches_expected(expected))
3127 .unwrap_or(false);
3128 if !matches {
3129 log_fingerprint_mismatch(index.fingerprint(), expected);
3130 return None;
3131 }
3132 }
3133 slog_info!(
3134 "loaded semantic index from disk: {} entries",
3135 index.entries.len()
3136 );
3137 Some(index)
3138 }
3139 Err(e) => {
3140 slog_warn!("corrupt semantic index, rebuilding: {}", e);
3141 if !is_worktree_bridge {
3142 let _ = fs::remove_file(&data_path);
3143 }
3144 None
3145 }
3146 }
3147 }
3148
3149 pub(crate) fn read_from_disk_borrow_tolerant(
3150 storage_dir: &Path,
3151 project_key: &str,
3152 current_canonical_root: &Path,
3153 ) -> Option<Self> {
3154 let data_path = storage_dir
3155 .join("semantic")
3156 .join(project_key)
3157 .join("semantic.bin");
3158 let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
3159 Ok(identity) => identity,
3160 Err(error) => {
3161 slog_warn!(
3162 "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
3163 error
3164 );
3165 return Self::read_from_disk(
3166 storage_dir,
3167 project_key,
3168 current_canonical_root,
3169 true,
3170 None,
3171 );
3172 }
3173 };
3174 let key = SharedSemanticBaseKey {
3175 artifact_cache_key: project_key.to_string(),
3176 fingerprint,
3177 artifact_content_hash,
3178 };
3179
3180 {
3181 let mut registry = shared_semantic_bases()
3182 .lock()
3183 .unwrap_or_else(std::sync::PoisonError::into_inner);
3184 registry.retain(|_, base| base.strong_count() > 0);
3185 if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
3186 SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3187 return Some(Self::from_shared_base(
3188 current_canonical_root.to_path_buf(),
3189 base,
3190 ));
3191 }
3192 if registry.keys().any(|existing| {
3193 existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3194 }) {
3195 slog_warn!(
3196 "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
3197 project_key
3198 );
3199 return Self::read_from_disk(
3200 storage_dir,
3201 project_key,
3202 current_canonical_root,
3203 true,
3204 None,
3205 );
3206 }
3207 }
3208
3209 let private = Self::read_from_disk(
3210 storage_dir,
3211 project_key,
3212 current_canonical_root,
3213 true,
3214 Some(&key.fingerprint),
3215 )?;
3216 let Some(base) = private.clone().into_shared_base() else {
3217 slog_warn!(
3218 "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
3219 project_key
3220 );
3221 return Some(private);
3222 };
3223 let base = Arc::new(base);
3224
3225 let mut registry = shared_semantic_bases()
3226 .lock()
3227 .unwrap_or_else(std::sync::PoisonError::into_inner);
3228 registry.retain(|_, base| base.strong_count() > 0);
3229 if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
3230 SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3231 return Some(Self::from_shared_base(
3232 current_canonical_root.to_path_buf(),
3233 existing,
3234 ));
3235 }
3236 if registry.keys().any(|existing| {
3237 existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3238 }) {
3239 slog_warn!(
3240 "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
3241 project_key
3242 );
3243 return Some(private);
3244 }
3245 registry.insert(key, Arc::downgrade(&base));
3246 SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
3247 Some(Self::from_shared_base(
3248 current_canonical_root.to_path_buf(),
3249 base,
3250 ))
3251 }
3252
3253 pub fn to_bytes(&self) -> Vec<u8> {
3255 if self.shared_base.is_some() {
3256 let mut private = self.clone();
3257 private.materialize_shared_base();
3258 return private.to_bytes();
3259 }
3260 let mut buf = Vec::new();
3261 self.write_to_writer(&mut buf)
3262 .expect("writing semantic index to Vec cannot fail");
3263 buf
3264 }
3265
3266 fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
3267 let mut bytes_written = 0usize;
3268 let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
3269 let encoded = fingerprint.as_string();
3270 if encoded.is_empty() {
3271 None
3272 } else {
3273 Some(encoded)
3274 }
3275 });
3276 let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
3277 let file_mtime_count = self
3278 .file_mtimes
3279 .iter()
3280 .filter(|(path, _)| cache_relative_path(&self.project_root, path).is_some())
3281 .count();
3282 let entry_count = self
3283 .entries
3284 .iter()
3285 .filter(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
3286 .count();
3287
3288 let version = SEMANTIC_INDEX_VERSION_V7;
3303 write_counted(writer, &[version], &mut bytes_written)?;
3304 write_counted(
3305 writer,
3306 &(self.dimension as u32).to_le_bytes(),
3307 &mut bytes_written,
3308 )?;
3309 write_counted(
3310 writer,
3311 &(entry_count as u32).to_le_bytes(),
3312 &mut bytes_written,
3313 )?;
3314 write_counted(
3315 writer,
3316 &(fp_bytes_ref.len() as u32).to_le_bytes(),
3317 &mut bytes_written,
3318 )?;
3319 write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
3320
3321 write_counted(
3324 writer,
3325 &(file_mtime_count as u32).to_le_bytes(),
3326 &mut bytes_written,
3327 )?;
3328 for (path, mtime) in &self.file_mtimes {
3329 let Some(relative) = cache_relative_path(&self.project_root, path) else {
3330 continue;
3331 };
3332 let relative = relative.to_string_lossy();
3333 let path_bytes = relative.as_bytes();
3334 write_counted(
3335 writer,
3336 &(path_bytes.len() as u32).to_le_bytes(),
3337 &mut bytes_written,
3338 )?;
3339 write_counted(writer, path_bytes, &mut bytes_written)?;
3340 let duration = mtime
3341 .duration_since(SystemTime::UNIX_EPOCH)
3342 .unwrap_or_default();
3343 write_counted(
3344 writer,
3345 &duration.as_secs().to_le_bytes(),
3346 &mut bytes_written,
3347 )?;
3348 write_counted(
3349 writer,
3350 &duration.subsec_nanos().to_le_bytes(),
3351 &mut bytes_written,
3352 )?;
3353 let size = self.file_sizes.get(path).copied().unwrap_or_default();
3354 write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
3355 let hash = self
3356 .file_hashes
3357 .get(path)
3358 .copied()
3359 .unwrap_or_else(cache_freshness::zero_hash);
3360 write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
3361 }
3362
3363 for entry in &self.entries {
3365 let Some(relative) = cache_relative_path(&self.project_root, &entry.chunk.file) else {
3366 continue;
3367 };
3368 let c = &entry.chunk;
3369
3370 let relative = relative.to_string_lossy();
3372 let file_bytes = relative.as_bytes();
3373 write_counted(
3374 writer,
3375 &(file_bytes.len() as u32).to_le_bytes(),
3376 &mut bytes_written,
3377 )?;
3378 write_counted(writer, file_bytes, &mut bytes_written)?;
3379
3380 let name_bytes = c.name.as_bytes();
3382 write_counted(
3383 writer,
3384 &(name_bytes.len() as u32).to_le_bytes(),
3385 &mut bytes_written,
3386 )?;
3387 write_counted(writer, name_bytes, &mut bytes_written)?;
3388
3389 let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
3391 write_counted(
3392 writer,
3393 &(qualified_name_bytes.len() as u32).to_le_bytes(),
3394 &mut bytes_written,
3395 )?;
3396 write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
3397
3398 write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
3400
3401 write_counted(
3403 writer,
3404 &(c.start_line as u32).to_le_bytes(),
3405 &mut bytes_written,
3406 )?;
3407 write_counted(
3408 writer,
3409 &(c.end_line as u32).to_le_bytes(),
3410 &mut bytes_written,
3411 )?;
3412 write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
3413
3414 let snippet_bytes = c.snippet.as_bytes();
3416 write_counted(
3417 writer,
3418 &(snippet_bytes.len() as u32).to_le_bytes(),
3419 &mut bytes_written,
3420 )?;
3421 write_counted(writer, snippet_bytes, &mut bytes_written)?;
3422
3423 let embed_bytes = c.embed_text.as_bytes();
3425 write_counted(
3426 writer,
3427 &(embed_bytes.len() as u32).to_le_bytes(),
3428 &mut bytes_written,
3429 )?;
3430 write_counted(writer, embed_bytes, &mut bytes_written)?;
3431
3432 for &val in &entry.vector {
3434 write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
3435 }
3436 }
3437
3438 Ok(bytes_written)
3439 }
3440
3441 pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
3443 debug_assert!(current_canonical_root.is_absolute());
3444 if data.len() < HEADER_BYTES_V1 {
3445 return Err("data too short".to_string());
3446 }
3447
3448 Self::from_reader_after_version(
3449 Cursor::new(&data[1..]),
3450 data[0],
3451 current_canonical_root,
3452 Some(data.len()),
3453 1,
3454 )
3455 }
3456
3457 fn from_reader_after_version<R: Read>(
3458 reader: R,
3459 version: u8,
3460 current_canonical_root: &Path,
3461 total_len: Option<usize>,
3462 bytes_read: usize,
3463 ) -> Result<Self, String> {
3464 debug_assert!(current_canonical_root.is_absolute());
3465 let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
3466
3467 if version != SEMANTIC_INDEX_VERSION_V1
3468 && version != SEMANTIC_INDEX_VERSION_V2
3469 && version != SEMANTIC_INDEX_VERSION_V3
3470 && version != SEMANTIC_INDEX_VERSION_V4
3471 && version != SEMANTIC_INDEX_VERSION_V5
3472 && version != SEMANTIC_INDEX_VERSION_V6
3473 && version != SEMANTIC_INDEX_VERSION_V7
3474 {
3475 return Err(format!("unsupported version: {}", version));
3476 }
3477 if (version == SEMANTIC_INDEX_VERSION_V2
3481 || version == SEMANTIC_INDEX_VERSION_V3
3482 || version == SEMANTIC_INDEX_VERSION_V4
3483 || version == SEMANTIC_INDEX_VERSION_V5
3484 || version == SEMANTIC_INDEX_VERSION_V6
3485 || version == SEMANTIC_INDEX_VERSION_V7)
3486 && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
3487 {
3488 return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
3489 }
3490
3491 let dimension = read_u32_stream(&mut reader)? as usize;
3492 let entry_count = read_u32_stream(&mut reader)? as usize;
3493 validate_embedding_dimension(dimension)?;
3494 if entry_count > MAX_ENTRIES {
3495 return Err(format!("too many semantic index entries: {}", entry_count));
3496 }
3497
3498 let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
3504 || version == SEMANTIC_INDEX_VERSION_V3
3505 || version == SEMANTIC_INDEX_VERSION_V4
3506 || version == SEMANTIC_INDEX_VERSION_V5
3507 || version == SEMANTIC_INDEX_VERSION_V6
3508 || version == SEMANTIC_INDEX_VERSION_V7;
3509 let fingerprint = if has_fingerprint_field {
3510 let fingerprint_len = read_u32_stream(&mut reader)? as usize;
3511 if total_len
3512 .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
3513 {
3514 return Err("unexpected end of data reading fingerprint".to_string());
3515 }
3516 if fingerprint_len == 0 {
3517 None
3518 } else {
3519 let mut raw = vec![0u8; fingerprint_len];
3520 read_exact_stream(
3521 &mut reader,
3522 &mut raw,
3523 "unexpected end of data reading fingerprint",
3524 )?;
3525 let raw = String::from_utf8_lossy(&raw).to_string();
3526 Some(
3527 serde_json::from_str::<SemanticIndexFingerprint>(&raw)
3528 .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
3529 )
3530 }
3531 } else {
3532 None
3533 };
3534
3535 let mtime_count = read_u32_stream(&mut reader)? as usize;
3537 if mtime_count > MAX_ENTRIES {
3538 return Err(format!("too many semantic file mtimes: {}", mtime_count));
3539 }
3540
3541 let vector_bytes = entry_count
3542 .checked_mul(dimension)
3543 .and_then(|count| count.checked_mul(F32_BYTES))
3544 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
3545 if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
3546 return Err("semantic index vectors exceed available data".to_string());
3547 }
3548
3549 let mut file_mtimes = HashMap::with_capacity(mtime_count);
3550 let mut file_sizes = HashMap::with_capacity(mtime_count);
3551 let mut file_hashes = HashMap::with_capacity(mtime_count);
3552 for _ in 0..mtime_count {
3553 let path = read_string_stream(&mut reader, total_len)?;
3554 let secs = read_u64_stream(&mut reader)?;
3555 let nanos = if version == SEMANTIC_INDEX_VERSION_V3
3561 || version == SEMANTIC_INDEX_VERSION_V4
3562 || version == SEMANTIC_INDEX_VERSION_V5
3563 || version == SEMANTIC_INDEX_VERSION_V6
3564 || version == SEMANTIC_INDEX_VERSION_V7
3565 {
3566 read_u32_stream(&mut reader)?
3567 } else {
3568 0
3569 };
3570 let size = if version == SEMANTIC_INDEX_VERSION_V5
3571 || version == SEMANTIC_INDEX_VERSION_V6
3572 || version == SEMANTIC_INDEX_VERSION_V7
3573 {
3574 read_u64_stream(&mut reader)?
3575 } else {
3576 0
3577 };
3578 let content_hash =
3579 if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
3580 let mut hash_bytes = [0u8; 32];
3581 read_exact_stream(
3582 &mut reader,
3583 &mut hash_bytes,
3584 "unexpected end of data reading content hash",
3585 )?;
3586 blake3::Hash::from_bytes(hash_bytes)
3587 } else {
3588 cache_freshness::zero_hash()
3589 };
3590 if nanos >= 1_000_000_000 {
3597 return Err(format!(
3598 "invalid semantic mtime: nanos {} >= 1_000_000_000",
3599 nanos
3600 ));
3601 }
3602 let duration = std::time::Duration::new(secs, nanos);
3603 let mtime = SystemTime::UNIX_EPOCH
3604 .checked_add(duration)
3605 .ok_or_else(|| {
3606 format!(
3607 "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
3608 secs, nanos
3609 )
3610 })?;
3611 let path = if version == SEMANTIC_INDEX_VERSION_V6
3612 || version == SEMANTIC_INDEX_VERSION_V7
3613 {
3614 cached_path_under_root(current_canonical_root, &PathBuf::from(path))
3615 .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
3616 } else {
3617 PathBuf::from(path)
3618 };
3619 file_mtimes.insert(path.clone(), mtime);
3620 file_sizes.insert(path.clone(), size);
3621 file_hashes.insert(path, content_hash);
3622 }
3623
3624 let mut entries = Vec::with_capacity(entry_count);
3626 for _ in 0..entry_count {
3627 let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
3628 let file = if version == SEMANTIC_INDEX_VERSION_V6
3629 || version == SEMANTIC_INDEX_VERSION_V7
3630 {
3631 cached_path_under_root(current_canonical_root, &raw_file)
3632 .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
3633 } else {
3634 raw_file
3635 };
3636 let name = read_string_stream(&mut reader, total_len)?;
3637 let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
3638 let qualified_name = read_string_stream(&mut reader, total_len)?;
3639 if qualified_name.is_empty() {
3640 None
3641 } else {
3642 Some(qualified_name)
3643 }
3644 } else {
3645 None
3646 };
3647
3648 let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
3649
3650 let start_line = read_u32_stream(&mut reader)?;
3651 let end_line = read_u32_stream(&mut reader)?;
3652
3653 let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
3654
3655 let snippet = read_string_stream(&mut reader, total_len)?;
3656 let embed_text = read_string_stream(&mut reader, total_len)?;
3657
3658 let vec_bytes = dimension
3660 .checked_mul(F32_BYTES)
3661 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
3662 if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
3663 return Err("unexpected end of data reading vector".to_string());
3664 }
3665 let mut vector = Vec::with_capacity(dimension);
3666 for _ in 0..dimension {
3667 let mut bytes = [0u8; F32_BYTES];
3668 read_exact_stream(
3669 &mut reader,
3670 &mut bytes,
3671 "unexpected end of data reading vector",
3672 )?;
3673 vector.push(f32::from_le_bytes(bytes));
3674 }
3675
3676 entries.push(EmbeddingEntry::new(
3677 SemanticChunk {
3678 file,
3679 name,
3680 qualified_name,
3681 kind,
3682 start_line,
3683 end_line,
3684 exported,
3685 embed_text,
3686 snippet,
3687 },
3688 vector,
3689 ));
3690 }
3691
3692 if entries.len() != entry_count {
3693 return Err(format!(
3694 "semantic cache entry count drift: header={} decoded={}",
3695 entry_count,
3696 entries.len()
3697 ));
3698 }
3699 for entry in &entries {
3700 if !file_mtimes.contains_key(&entry.chunk.file) {
3701 return Err(format!(
3702 "semantic cache metadata missing for entry file {}",
3703 entry.chunk.file.display()
3704 ));
3705 }
3706 }
3707
3708 let any_missing_sizes = file_mtimes
3709 .keys()
3710 .any(|path| !file_sizes.contains_key(path));
3711 Ok(Self {
3712 entries,
3713 file_mtimes,
3714 file_sizes,
3715 any_missing_sizes,
3716 file_hashes,
3717 dimension,
3718 fingerprint,
3719 project_root: current_canonical_root.to_path_buf(),
3720 deferred_files: HashSet::new(),
3721 shared_base: None,
3722 })
3723 }
3724}
3725
3726fn write_counted<W: Write>(
3727 writer: &mut W,
3728 bytes: &[u8],
3729 bytes_written: &mut usize,
3730) -> io::Result<()> {
3731 writer.write_all(bytes)?;
3732 *bytes_written = bytes_written.saturating_add(bytes.len());
3733 Ok(())
3734}
3735
3736struct CountingReader<R> {
3737 inner: R,
3738 bytes_read: usize,
3739}
3740
3741impl<R> CountingReader<R> {
3742 fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
3743 Self { inner, bytes_read }
3744 }
3745
3746 fn bytes_read(&self) -> usize {
3747 self.bytes_read
3748 }
3749}
3750
3751impl<R: Read> Read for CountingReader<R> {
3752 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
3753 let read = self.inner.read(buf)?;
3754 self.bytes_read = self.bytes_read.saturating_add(read);
3755 Ok(read)
3756 }
3757}
3758
3759fn read_exact_stream<R: Read>(
3760 reader: &mut CountingReader<R>,
3761 buf: &mut [u8],
3762 eof_message: &'static str,
3763) -> Result<(), String> {
3764 reader.read_exact(buf).map_err(|error| {
3765 if error.kind() == io::ErrorKind::UnexpectedEof {
3766 eof_message.to_string()
3767 } else {
3768 format!("{eof_message}: {error}")
3769 }
3770 })
3771}
3772
3773fn read_u8_stream<R: Read>(
3774 reader: &mut CountingReader<R>,
3775 eof_message: &'static str,
3776) -> Result<u8, String> {
3777 let mut bytes = [0u8; 1];
3778 read_exact_stream(reader, &mut bytes, eof_message)?;
3779 Ok(bytes[0])
3780}
3781
3782fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
3783 let mut bytes = [0u8; 4];
3784 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
3785 Ok(u32::from_le_bytes(bytes))
3786}
3787
3788fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
3789 let mut bytes = [0u8; 8];
3790 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
3791 Ok(u64::from_le_bytes(bytes))
3792}
3793
3794fn read_string_stream<R: Read>(
3795 reader: &mut CountingReader<R>,
3796 total_len: Option<usize>,
3797) -> Result<String, String> {
3798 let len = read_u32_stream(reader)? as usize;
3799 if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
3800 return Err("unexpected end of data reading string".to_string());
3801 }
3802 let mut bytes = vec![0u8; len];
3803 read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
3804 Ok(String::from_utf8_lossy(&bytes).to_string())
3805}
3806
3807struct SourceLineCache<'a> {
3808 lines: Vec<&'a str>,
3809 line_starts: Vec<usize>,
3810}
3811
3812impl<'a> SourceLineCache<'a> {
3813 fn new(source: &'a str) -> Self {
3814 let lines: Vec<&'a str> = source.lines().collect();
3815 let mut line_starts = Vec::with_capacity(lines.len());
3816 let bytes = source.as_bytes();
3817 let mut offset = 0usize;
3818 for line in &lines {
3819 line_starts.push(offset);
3820 offset += line.len();
3821 if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
3822 offset += 2;
3823 } else if bytes.get(offset) == Some(&b'\n') {
3824 offset += 1;
3825 }
3826 }
3827 Self { lines, line_starts }
3828 }
3829
3830 fn len(&self) -> usize {
3831 debug_assert_eq!(self.lines.len(), self.line_starts.len());
3832 self.line_starts.len()
3833 }
3834}
3835
3836fn build_embed_text_with_lines(
3838 symbol: &Symbol,
3839 line_cache: &SourceLineCache<'_>,
3840 file: &Path,
3841 project_root: &Path,
3842) -> String {
3843 let relative = file
3844 .strip_prefix(project_root)
3845 .unwrap_or(file)
3846 .to_string_lossy();
3847
3848 let kind_label = match &symbol.kind {
3849 SymbolKind::Function => "function",
3850 SymbolKind::Class => "class",
3851 SymbolKind::Method => "method",
3852 SymbolKind::Struct => "struct",
3853 SymbolKind::Interface => "interface",
3854 SymbolKind::Enum => "enum",
3855 SymbolKind::TypeAlias => "type",
3856 SymbolKind::Variable => "variable",
3857 SymbolKind::Heading => "heading",
3858 SymbolKind::FileSummary => "file-summary",
3859 };
3860
3861 let name = &symbol.name;
3863 let mut text = format!(
3864 "name:{name} file:{} kind:{} name:{name}",
3865 relative, kind_label
3866 );
3867
3868 if let Some(sig) = &symbol.signature {
3869 text.push_str(&format!(" signature:{}", truncate_chars(sig, 400)));
3877 }
3878
3879 let start = (symbol.range.start_line as usize).min(line_cache.len());
3881 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
3883 if start < end {
3884 let body: String = line_cache.lines[start..end]
3885 .iter()
3886 .take(15) .copied()
3888 .collect::<Vec<&str>>()
3889 .join("\n");
3890 let snippet = if body.len() > 300 {
3891 format!("{}...", &body[..body.floor_char_boundary(300)])
3892 } else {
3893 body
3894 };
3895 text.push_str(&format!(" body:{}", snippet));
3896 }
3897
3898 truncate_chars(&text, MAX_EMBED_TEXT_CHARS)
3903}
3904
3905#[cfg(test)]
3906fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
3907 let line_cache = SourceLineCache::new(source);
3908 build_embed_text_with_lines(symbol, &line_cache, file, project_root)
3909}
3910
3911const MAX_EMBED_TEXT_CHARS: usize = 1600;
3915
3916fn truncate_chars(value: &str, max_chars: usize) -> String {
3917 value.chars().take(max_chars).collect()
3918}
3919
3920fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
3921 let Some((start, first)) = line_cache
3922 .lines
3923 .iter()
3924 .enumerate()
3925 .find(|(_, line)| !line.trim().is_empty())
3926 else {
3927 return String::new();
3928 };
3929
3930 let trimmed = first.trim_start();
3931 if trimmed.starts_with("/**") {
3932 let mut comment = Vec::new();
3933 for line in line_cache.lines.iter().skip(start) {
3934 comment.push(*line);
3935 if line.contains("*/") {
3936 break;
3937 }
3938 }
3939 return truncate_chars(&comment.join("\n"), 200);
3940 }
3941
3942 if trimmed.starts_with("///") || trimmed.starts_with("//!") {
3943 let comment = line_cache
3944 .lines
3945 .iter()
3946 .skip(start)
3947 .take_while(|line| {
3948 let trimmed = line.trim_start();
3949 trimmed.starts_with("///") || trimmed.starts_with("//!")
3950 })
3951 .copied()
3952 .collect::<Vec<_>>()
3953 .join("\n");
3954 return truncate_chars(&comment, 200);
3955 }
3956
3957 String::new()
3958}
3959
3960pub fn build_file_summary_chunk(
3961 file: &Path,
3962 project_root: &Path,
3963 source: &str,
3964 top_exports: &[&str],
3965 top_export_signatures: &[Option<&str>],
3966) -> SemanticChunk {
3967 let line_cache = SourceLineCache::new(source);
3968 build_file_summary_chunk_with_lines(
3969 file,
3970 project_root,
3971 &line_cache,
3972 top_exports,
3973 top_export_signatures,
3974 )
3975}
3976
3977fn build_file_summary_chunk_with_lines(
3978 file: &Path,
3979 project_root: &Path,
3980 line_cache: &SourceLineCache<'_>,
3981 top_exports: &[&str],
3982 top_export_signatures: &[Option<&str>],
3983) -> SemanticChunk {
3984 let relative = file.strip_prefix(project_root).unwrap_or(file);
3985 let rel_path = relative.to_string_lossy();
3986 let parent_dir = relative
3987 .parent()
3988 .map(|parent| parent.to_string_lossy().to_string())
3989 .unwrap_or_default();
3990 let name = file
3991 .file_stem()
3992 .map(|stem| stem.to_string_lossy().to_string())
3993 .unwrap_or_default();
3994 let doc = first_leading_doc_comment(line_cache);
3995 let exports = top_exports
3996 .iter()
3997 .take(5)
3998 .copied()
3999 .collect::<Vec<_>>()
4000 .join(",");
4001 let snippet = if doc.is_empty() {
4002 top_export_signatures
4003 .first()
4004 .and_then(|signature| signature.as_deref())
4005 .map(|signature| truncate_chars(signature, 200))
4006 .unwrap_or_default()
4007 } else {
4008 doc.clone()
4009 };
4010
4011 SemanticChunk {
4012 file: file.to_path_buf(),
4013 name,
4014 qualified_name: None,
4015 kind: SymbolKind::FileSummary,
4016 start_line: 0,
4017 end_line: 0,
4018 exported: false,
4019 embed_text: truncate_chars(
4020 &format!(
4021 "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
4022 file.file_stem()
4023 .map(|stem| stem.to_string_lossy().to_string())
4024 .unwrap_or_default()
4025 ),
4026 MAX_EMBED_TEXT_CHARS,
4027 ),
4028 snippet,
4029 }
4030}
4031
4032pub fn is_semantic_indexed_extension(path: &Path) -> bool {
4033 if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
4034 return true;
4035 }
4036
4037 matches!(
4038 path.extension().and_then(|extension| extension.to_str()),
4039 Some(
4040 "ts" | "tsx"
4041 | "js"
4042 | "jsx"
4043 | "py"
4044 | "rs"
4045 | "go"
4046 | "c"
4047 | "h"
4048 | "cc"
4049 | "cpp"
4050 | "cxx"
4051 | "hpp"
4052 | "hh"
4053 | "zig"
4054 | "cs"
4055 | "sh"
4056 | "bash"
4057 | "zsh"
4058 | "inc"
4059 | "php"
4060 | "sol"
4061 | "scss"
4062 | "vue"
4063 | "yaml"
4064 | "yml"
4065 | "pas"
4066 | "pp"
4067 | "dpr"
4068 | "dpk"
4069 | "lpr"
4070 | "java"
4071 | "kt"
4072 | "kts"
4073 | "rb"
4074 | "swift"
4075 | "scala"
4076 | "sc"
4077 | "lua"
4078 | "pl"
4079 | "pm"
4080 | "t"
4081 | "r"
4082 | "R"
4083 | "groovy"
4084 | "gvy"
4085 | "gy"
4086 | "gsh"
4087 | "gradle"
4088 | "m"
4089 | "mm",
4090 )
4091 )
4092}
4093
4094fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
4095 if let Ok(canonical) = fs::canonicalize(path) {
4096 return canonical;
4097 }
4098
4099 let Some(parent) = path.parent() else {
4100 return path.to_path_buf();
4101 };
4102 let Some(file_name) = path.file_name() else {
4103 return path.to_path_buf();
4104 };
4105
4106 fs::canonicalize(parent)
4107 .map(|canonical_parent| canonical_parent.join(file_name))
4108 .unwrap_or_else(|_| path.to_path_buf())
4109}
4110
4111const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
4121
4122fn collect_semantic_file(
4123 project_root: &Path,
4124 file: &Path,
4125 phases: &mut SemanticCollectPhaseTimings,
4126) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
4127 let read_hash_started = Instant::now();
4128 let read_result = (|| {
4129 let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
4130 if !metadata.is_file() {
4131 return Err("not a regular file".to_string());
4132 }
4133 let mtime = metadata.modified().map_err(|error| error.to_string())?;
4134 let size = metadata.len();
4135
4136 if !is_semantic_indexed_extension(file) {
4137 return Err("unsupported file extension".to_string());
4138 }
4139 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4140
4141 let mut indexed_metadata = IndexedFileMetadata {
4142 mtime,
4143 size,
4144 content_hash: cache_freshness::zero_hash(),
4145 };
4146
4147 if size > MAX_SEMANTIC_FILE_BYTES {
4150 return Ok((indexed_metadata, lang, None));
4151 }
4152
4153 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4154 indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
4155 cache_freshness::hash_bytes(source.as_bytes())
4156 } else {
4157 cache_freshness::zero_hash()
4158 };
4159 Ok((indexed_metadata, lang, Some(source)))
4160 })();
4161 phases.read_hash += read_hash_started.elapsed();
4162 let (indexed_metadata, lang, source) = read_result?;
4163 let Some(source) = source else {
4164 return Ok((indexed_metadata, Vec::new()));
4165 };
4166
4167 let chunks = collect_file_chunks_from_source_timed(project_root, file, lang, &source, phases)?;
4168 Ok((indexed_metadata, chunks))
4169}
4170
4171#[cfg(test)]
4172fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
4173 if !is_semantic_indexed_extension(file) {
4174 return Err("unsupported file extension".to_string());
4175 }
4176 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4177 if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
4180 return Ok(Vec::new());
4181 }
4182 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4183 collect_file_chunks_from_source(project_root, file, lang, &source)
4184}
4185
4186#[cfg(test)]
4187fn collect_file_chunks_from_source(
4188 project_root: &Path,
4189 file: &Path,
4190 lang: crate::parser::LangId,
4191 source: &str,
4192) -> Result<Vec<SemanticChunk>, String> {
4193 collect_file_chunks_from_source_timed(
4194 project_root,
4195 file,
4196 lang,
4197 source,
4198 &mut SemanticCollectPhaseTimings::default(),
4199 )
4200}
4201
4202fn collect_file_chunks_from_source_timed(
4203 project_root: &Path,
4204 file: &Path,
4205 lang: crate::parser::LangId,
4206 source: &str,
4207 phases: &mut SemanticCollectPhaseTimings,
4208) -> Result<Vec<SemanticChunk>, String> {
4209 let parse_started = Instant::now();
4210 let tree_result =
4211 parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
4212 phases.parse += parse_started.elapsed();
4213 let tree = tree_result?;
4214
4215 let extract_started = Instant::now();
4216 let symbols_result =
4217 extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
4218 phases.extract += extract_started.elapsed();
4219 let symbols = symbols_result?;
4220
4221 let build_started = Instant::now();
4222 let chunks = symbols_to_chunks(file, &symbols, source, project_root);
4223 phases.build += build_started.elapsed();
4224 Ok(chunks)
4225}
4226
4227fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
4229 let start = (symbol.range.start_line as usize).min(line_cache.len());
4230 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4232 if start < end {
4233 let snippet_lines: Vec<&str> = line_cache.lines[start..end]
4234 .iter()
4235 .take(5)
4236 .copied()
4237 .collect();
4238 let mut snippet = snippet_lines.join("\n");
4239 if end - start > 5 {
4240 snippet.push_str("\n ...");
4241 }
4242 if snippet.len() > 300 {
4243 snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
4244 }
4245 snippet
4246 } else {
4247 String::new()
4248 }
4249}
4250
4251#[cfg(test)]
4252fn build_snippet(symbol: &Symbol, source: &str) -> String {
4253 let line_cache = SourceLineCache::new(source);
4254 build_snippet_with_lines(symbol, &line_cache)
4255}
4256
4257fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
4258 let mut parts = symbol
4259 .scope_chain
4260 .iter()
4261 .filter(|part| !part.is_empty())
4262 .cloned()
4263 .collect::<Vec<_>>();
4264 if !symbol.name.is_empty() {
4265 parts.push(symbol.name.clone());
4266 }
4267 (!parts.is_empty()).then(|| parts.join("."))
4268}
4269
4270fn symbols_to_chunks(
4272 file: &Path,
4273 symbols: &[Symbol],
4274 source: &str,
4275 project_root: &Path,
4276) -> Vec<SemanticChunk> {
4277 let line_cache = SourceLineCache::new(source);
4278 let mut chunks = Vec::new();
4279 let top_exports_with_signatures = symbols
4280 .iter()
4281 .filter(|symbol| {
4282 symbol.exported
4283 && symbol.parent.is_none()
4284 && !matches!(symbol.kind, SymbolKind::Heading)
4285 })
4286 .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
4287 .collect::<Vec<_>>();
4288
4289 let has_only_headings = !symbols.is_empty()
4290 && symbols
4291 .iter()
4292 .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
4293 if top_exports_with_signatures.len() <= 2 && !has_only_headings {
4294 let top_exports = top_exports_with_signatures
4295 .iter()
4296 .map(|(name, _)| *name)
4297 .collect::<Vec<_>>();
4298 let top_export_signatures = top_exports_with_signatures
4299 .iter()
4300 .map(|(_, signature)| *signature)
4301 .collect::<Vec<_>>();
4302 chunks.push(build_file_summary_chunk_with_lines(
4303 file,
4304 project_root,
4305 &line_cache,
4306 &top_exports,
4307 &top_export_signatures,
4308 ));
4309 }
4310
4311 for symbol in symbols {
4312 if matches!(symbol.kind, SymbolKind::Heading) {
4317 continue;
4318 }
4319
4320 let line_count = symbol
4322 .range
4323 .end_line
4324 .saturating_sub(symbol.range.start_line)
4325 + 1;
4326 if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
4327 continue;
4328 }
4329
4330 let embed_text = build_embed_text_with_lines(symbol, &line_cache, file, project_root);
4331 let snippet = build_snippet_with_lines(symbol, &line_cache);
4332
4333 chunks.push(SemanticChunk {
4334 file: file.to_path_buf(),
4335 name: symbol.name.clone(),
4336 qualified_name: qualified_name_for_symbol(symbol),
4337 kind: symbol.kind.clone(),
4338 start_line: symbol.range.start_line,
4339 end_line: symbol.range.end_line,
4340 exported: symbol.exported,
4341 embed_text,
4342 snippet,
4343 });
4344
4345 }
4348
4349 chunks
4350}
4351
4352fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
4353 b.0.partial_cmp(&a.0)
4354 .unwrap_or(std::cmp::Ordering::Equal)
4355 .then_with(|| a.1.cmp(&b.1))
4356}
4357
4358fn vector_norm(vector: &[f32]) -> f32 {
4360 vector.iter().map(|value| value * value).sum::<f32>().sqrt()
4361}
4362
4363fn dot_product(a: &[f32], b: &[f32]) -> f32 {
4364 a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
4365}
4366
4367#[cfg(test)]
4369fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
4370 if a.len() != b.len() {
4371 return 0.0;
4372 }
4373
4374 let mut dot = 0.0f32;
4375 let mut norm_a = 0.0f32;
4376 let mut norm_b = 0.0f32;
4377
4378 for i in 0..a.len() {
4379 dot += a[i] * b[i];
4380 norm_a += a[i] * a[i];
4381 norm_b += b[i] * b[i];
4382 }
4383
4384 let denom = norm_a.sqrt() * norm_b.sqrt();
4385 if denom == 0.0 {
4386 0.0
4387 } else {
4388 dot / denom
4389 }
4390}
4391
4392fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
4394 match kind {
4395 SymbolKind::Function => 0,
4396 SymbolKind::Class => 1,
4397 SymbolKind::Method => 2,
4398 SymbolKind::Struct => 3,
4399 SymbolKind::Interface => 4,
4400 SymbolKind::Enum => 5,
4401 SymbolKind::TypeAlias => 6,
4402 SymbolKind::Variable => 7,
4403 SymbolKind::Heading => 8,
4404 SymbolKind::FileSummary => 9,
4405 }
4406}
4407
4408fn u8_to_symbol_kind(v: u8) -> SymbolKind {
4409 match v {
4410 0 => SymbolKind::Function,
4411 1 => SymbolKind::Class,
4412 2 => SymbolKind::Method,
4413 3 => SymbolKind::Struct,
4414 4 => SymbolKind::Interface,
4415 5 => SymbolKind::Enum,
4416 6 => SymbolKind::TypeAlias,
4417 7 => SymbolKind::Variable,
4418 8 => SymbolKind::Heading,
4419 9 => SymbolKind::FileSummary,
4420 _ => SymbolKind::Heading,
4421 }
4422}
4423
4424#[cfg(test)]
4425mod tests {
4426 use super::*;
4427 use crate::config::{SemanticBackend, SemanticBackendConfig};
4428 use crate::parser::FileParser;
4429 use std::io::{Read, Write};
4430 use std::net::TcpListener;
4431 use std::thread;
4432
4433 #[cfg(unix)]
4436 const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
4437 "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
4438
4439 #[cfg(unix)]
4440 fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
4441 let fixture_root = project_root.join("tests/fixtures");
4442 let lf_root = tempfile::tempdir().expect("lf fixture root");
4447 let fixture_files = [
4448 "imports_rs.rs",
4449 "member_rs.rs",
4450 "sample.rs",
4451 "structure_rs.rs",
4452 ]
4453 .map(|name| {
4454 let source = std::fs::read_to_string(fixture_root.join(name))
4455 .expect("read fixture")
4456 .replace("\r\n", "\n");
4457 let path = lf_root.path().join("tests/fixtures").join(name);
4461 std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
4462 std::fs::write(&path, source).expect("write LF fixture");
4463 path
4464 });
4465 let project_root = lf_root.path();
4466 let (chunks, _) = SemanticIndex::collect_chunks(project_root, &fixture_files);
4467 let normalized = chunks
4468 .iter()
4469 .map(|chunk| {
4470 (
4471 chunk
4472 .file
4473 .strip_prefix(project_root)
4474 .unwrap()
4475 .to_string_lossy()
4476 .replace('\\', "/"),
4477 &chunk.name,
4478 &chunk.qualified_name,
4479 &chunk.kind,
4480 chunk.start_line,
4481 chunk.end_line,
4482 chunk.exported,
4483 &chunk.embed_text,
4484 &chunk.snippet,
4485 )
4486 })
4487 .collect::<Vec<_>>();
4488 let output = format!("{normalized:#?}");
4489 (
4490 chunks.len(),
4491 output.len(),
4492 blake3::hash(output.as_bytes()).to_hex().to_string(),
4493 )
4494 }
4495
4496 #[cfg(unix)]
4503 #[test]
4504 fn rust_semantic_fixture_output_matches_query_baseline() {
4505 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4506 let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
4507 assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
4508 }
4509
4510 #[test]
4511 #[ignore = "manual single-file semantic collect phase benchmark"]
4512 fn profile_rust_single_file_semantic_collect() {
4513 let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4514 let workspace_root = crate_root
4515 .parent()
4516 .and_then(Path::parent)
4517 .expect("workspace root");
4518 let files = [
4519 workspace_root.join("crates/aft/src/bash_background/registry.rs"),
4520 workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
4521 ];
4522
4523 for file in files {
4524 let source = fs::read_to_string(&file).expect("read benchmark source");
4525 for run in 1..=5 {
4526 let mut phases = SemanticCollectPhaseTimings::default();
4527 let started = Instant::now();
4528 let chunks = collect_file_chunks_from_source_timed(
4529 workspace_root,
4530 &file,
4531 crate::parser::LangId::Rust,
4532 &source,
4533 &mut phases,
4534 )
4535 .unwrap();
4536 eprintln!(
4537 "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
4538 file.strip_prefix(workspace_root).unwrap().display(),
4539 source.len(),
4540 started.elapsed(),
4541 phases.parse,
4542 phases.extract,
4543 phases.build,
4544 chunks.len()
4545 );
4546 }
4547 }
4548 }
4549
4550 #[test]
4551 fn semantic_index_includes_php_inc_and_scss_extensions() {
4552 for file in ["partial.inc", "index.php", "styles.scss"] {
4553 assert!(
4554 is_semantic_indexed_extension(Path::new(file)),
4555 "{file} should be semantic-index eligible"
4556 );
4557 }
4558 }
4559
4560 #[test]
4561 fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
4562 for file in [
4563 "script.groovy",
4564 "script.gvy",
4565 "script.gy",
4566 "shell.gsh",
4567 "build.gradle",
4568 "Jenkinsfile",
4569 ] {
4570 assert!(
4571 is_semantic_indexed_extension(Path::new(file)),
4572 "{file} should be semantic-index eligible"
4573 );
4574 }
4575 assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
4576 }
4577
4578 #[test]
4579 fn transient_marker_round_trips_and_classifies() {
4580 let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
4583 assert!(embedding_failure_is_transient(&marked));
4584 let clean = strip_transient_embedding_marker(&marked);
4585 assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
4586 assert!(clean.starts_with("openai compatible request failed:"));
4587
4588 for permanent in [
4591 "openai compatible request failed (HTTP 401): Unauthorized",
4592 "embedding dimension mismatch: index has 384, model returned 768",
4593 "too many files (>20000) for semantic indexing (max 20000)",
4594 ] {
4595 assert!(
4596 !embedding_failure_is_transient(permanent),
4597 "{permanent:?} must not be transient"
4598 );
4599 assert_eq!(strip_transient_embedding_marker(permanent), permanent);
4601 }
4602 }
4603
4604 #[test]
4605 fn send_error_transience_separates_connect_timeout_from_4xx() {
4606 assert!(is_retryable_embedding_status(
4608 reqwest::StatusCode::INTERNAL_SERVER_ERROR
4609 ));
4610 assert!(is_retryable_embedding_status(
4611 reqwest::StatusCode::TOO_MANY_REQUESTS
4612 ));
4613 assert!(!is_retryable_embedding_status(
4614 reqwest::StatusCode::UNAUTHORIZED
4615 ));
4616 assert!(!is_retryable_embedding_status(
4617 reqwest::StatusCode::BAD_REQUEST
4618 ));
4619 }
4620
4621 #[test]
4622 fn local_backend_model_loading_body_is_transient() {
4623 for body in [
4626 r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
4627 r#"{"error":"model is loading, please wait"}"#,
4628 r#"{"error":"Model not loaded"}"#,
4629 "Loading model into memory",
4630 ] {
4631 assert!(
4632 embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
4633 "{body:?} should be body-transient"
4634 );
4635 }
4636
4637 for body in [
4641 r#"{"error":"invalid api key"}"#,
4642 r#"{"error":"model 'foo' not found"}"#,
4643 "Bad Request: unknown field",
4644 "Bad Request: invalid loading model option",
4645 r#"{"error":"unauthorized while model is being loaded by another account"}"#,
4646 ] {
4647 assert!(
4648 !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
4649 "{body:?} must not be body-transient"
4650 );
4651 }
4652
4653 assert!(
4654 !embedding_response_body_is_transient(
4655 reqwest::StatusCode::UNAUTHORIZED,
4656 r#"{"error":"model is loading, please wait"}"#
4657 ),
4658 "permanent auth failures must not become transient because of body text"
4659 );
4660 }
4661
4662 fn start_slow_embedding_server(
4663 expected_requests: usize,
4664 response_delay: Duration,
4665 ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
4666 let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
4667 listener
4668 .set_nonblocking(true)
4669 .expect("set slow server nonblocking");
4670 let addr = listener.local_addr().expect("slow embedding server addr");
4671 let requests = Arc::new(AtomicUsize::new(0));
4672 let requests_for_thread = Arc::clone(&requests);
4673 let handle = thread::spawn(move || {
4674 let deadline = Instant::now() + Duration::from_secs(10);
4675 let mut handlers = Vec::new();
4676 while requests_for_thread.load(Ordering::SeqCst) < expected_requests
4677 && Instant::now() < deadline
4678 {
4679 match listener.accept() {
4680 Ok((mut stream, _)) => {
4681 requests_for_thread.fetch_add(1, Ordering::SeqCst);
4682 handlers.push(thread::spawn(move || {
4683 let mut request = [0u8; 4096];
4684 let _ = stream.read(&mut request);
4685 thread::sleep(response_delay);
4686 let body =
4687 r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
4688 let response = format!(
4689 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4690 body.len(),
4691 body
4692 );
4693 let _ = stream.write_all(response.as_bytes());
4694 }));
4695 }
4696 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
4697 thread::sleep(Duration::from_millis(5));
4698 }
4699 Err(error) => panic!("accept slow embedding request: {error}"),
4700 }
4701 }
4702 for handler in handlers {
4703 handler.join().expect("slow embedding handler");
4704 }
4705 });
4706
4707 (format!("http://{addr}"), requests, handle)
4708 }
4709
4710 fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
4711 where
4712 F: Fn(String, String, String) -> String + Send + 'static,
4713 {
4714 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4715 let addr = listener.local_addr().expect("local addr");
4716 let handle = thread::spawn(move || {
4717 let (mut stream, _) = listener.accept().expect("accept request");
4718 let mut buf = Vec::new();
4719 let mut chunk = [0u8; 4096];
4720 let mut header_end = None;
4721 let mut content_length = 0usize;
4722 loop {
4723 let n = stream.read(&mut chunk).expect("read request");
4724 if n == 0 {
4725 break;
4726 }
4727 buf.extend_from_slice(&chunk[..n]);
4728 if header_end.is_none() {
4729 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
4730 header_end = Some(pos + 4);
4731 let headers = String::from_utf8_lossy(&buf[..pos + 4]);
4732 for line in headers.lines() {
4733 if let Some(value) = line.strip_prefix("Content-Length:") {
4734 content_length = value.trim().parse::<usize>().unwrap_or(0);
4735 }
4736 }
4737 }
4738 }
4739 if let Some(end) = header_end {
4740 if buf.len() >= end + content_length {
4741 break;
4742 }
4743 }
4744 }
4745
4746 let end = header_end.expect("header terminator");
4747 let request = String::from_utf8_lossy(&buf[..end]).to_string();
4748 let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
4749 let mut lines = request.lines();
4750 let request_line = lines.next().expect("request line").to_string();
4751 let path = request_line
4752 .split_whitespace()
4753 .nth(1)
4754 .expect("request path")
4755 .to_string();
4756 let response_body = handler(request_line, path, body);
4757 let response = format!(
4758 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4759 response_body.len(),
4760 response_body
4761 );
4762 stream
4763 .write_all(response.as_bytes())
4764 .expect("write response");
4765 });
4766
4767 (format!("http://{}", addr), handle)
4768 }
4769
4770 fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
4771 let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
4772 listener
4773 .set_nonblocking(true)
4774 .expect("nonblocking listener");
4775 let addr = listener.local_addr().expect("local addr");
4776 let handle = thread::spawn(move || {
4777 let deadline = std::time::Instant::now() + Duration::from_secs(30);
4787 let mut accepted = 0usize;
4788 while accepted < attempts && std::time::Instant::now() < deadline {
4789 match listener.accept() {
4790 Ok((mut stream, _)) => {
4791 accepted += 1;
4792 let mut buf = [0u8; 4096];
4793 let _ = stream.read(&mut buf);
4801 let response = "HTTP/1.1 200 OK
4802Content-Type: application/json
4803Content-Length: 128
4804Connection: close
4805
4806{";
4807 let _ = stream.write_all(response.as_bytes());
4808 }
4809 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
4810 thread::sleep(Duration::from_millis(10));
4811 }
4812 Err(error) => panic!("accept request: {error}"),
4813 }
4814 }
4815 });
4816
4817 (format!("http://{}", addr), handle)
4818 }
4819
4820 #[test]
4821 fn response_body_read_failures_are_marked_transient() {
4822 let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
4823 let client = Client::builder()
4827 .timeout(Duration::from_secs(5))
4828 .build()
4829 .expect("client");
4830
4831 let error = send_embedding_request(
4832 || client.post(&url).body("{}"),
4833 "test backend",
4834 EmbeddingRequestPolicy::Build,
4835 )
4836 .expect_err("truncated body should fail");
4837
4838 handle.join().unwrap();
4839 assert!(
4840 embedding_failure_is_transient(&error),
4841 "body read failures should be transient-marked: {error}"
4842 );
4843 assert!(
4851 error.contains("response read failed") || error.contains("request failed"),
4852 "unexpected error shape: {error}"
4853 );
4854 }
4855
4856 fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
4857 Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
4858 }
4859
4860 fn write_rust_file(path: &Path, function_name: &str) {
4861 fs::write(
4862 path,
4863 format!("pub fn {function_name}() -> bool {{\n true\n}}\n"),
4864 )
4865 .unwrap();
4866 }
4867
4868 fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
4869 let mut embed = test_vector_for_texts;
4870 SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
4871 }
4872
4873 fn test_project_root() -> PathBuf {
4874 std::env::current_dir().unwrap()
4875 }
4876
4877 #[test]
4878 fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
4879 let project = tempfile::tempdir().expect("create project");
4880 let storage = tempfile::tempdir().expect("create storage");
4881 let source = project.path().join("lib.rs");
4882 write_rust_file(&source, "persisted_symbol");
4883 let populated = build_test_index(project.path(), std::slice::from_ref(&source));
4884 assert!(populated.write_to_disk(storage.path(), "project"));
4885
4886 let data_path = storage.path().join("semantic/project/semantic.bin");
4887 let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
4888 let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
4889 assert!(empty.write_to_disk(storage.path(), "project"));
4890 let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
4891 assert_ne!(empty_bytes, populated_bytes);
4892 let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
4893 .expect("decode explicit empty snapshot");
4894 assert_eq!(decoded.entry_count(), 0);
4895 for _ in 0..2 {
4896 let loaded = SemanticIndex::read_from_disk(
4897 storage.path(),
4898 "project",
4899 project.path(),
4900 false,
4901 None,
4902 )
4903 .expect("explicit empty snapshot remains loadable");
4904 assert_eq!(loaded.entry_count(), 0);
4905 }
4906 }
4907
4908 #[test]
4909 fn persistence_failure_is_reported_to_caller() {
4910 let project = tempfile::tempdir().expect("create project");
4911 let storage_parent = tempfile::tempdir().expect("create storage parent");
4912 let storage_file = storage_parent.path().join("not-a-directory");
4913 fs::write(&storage_file, b"occupied").expect("create blocking file");
4914 let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
4915
4916 assert!(!empty.write_to_disk(&storage_file, "project"));
4917 }
4918
4919 #[test]
4920 fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
4921 let root = test_project_root();
4922 let mut index = SemanticIndex::new(root.clone(), 3);
4923 assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
4924
4925 let entry = |name: &str| EmbeddingEntry {
4926 chunk: SemanticChunk {
4927 file: root.join(format!("{name}.rs")),
4928 name: name.to_string(),
4929 qualified_name: Some(format!("module::{name}")),
4930 kind: SymbolKind::Function,
4931 start_line: 0,
4932 end_line: 1,
4933 exported: true,
4934 embed_text: format!("function {name} body"),
4935 snippet: format!("fn {name}() {{}}"),
4936 },
4937 norm: vector_norm(&[1.0, 2.0, 3.0]),
4938 vector: vec![1.0, 2.0, 3.0],
4939 };
4940 index.entries.push(entry("one"));
4941 let one_entry = index.estimated_memory().estimated_bytes.unwrap();
4942 assert!(one_entry > 0);
4943 index.entries.push(entry("two"));
4944 let two_entries = index.estimated_memory().estimated_bytes.unwrap();
4945 assert!(two_entries > one_entry);
4946 }
4947
4948 fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
4949 index.file_mtimes.insert(file.to_path_buf(), mtime);
4950 index.file_sizes.insert(file.to_path_buf(), size);
4951 index
4952 .file_hashes
4953 .insert(file.to_path_buf(), cache_freshness::zero_hash());
4954 }
4955
4956 fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
4957 let mut buf = Vec::new();
4958 let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
4959 let encoded = fingerprint.as_string();
4960 if encoded.is_empty() {
4961 None
4962 } else {
4963 Some(encoded.into_bytes())
4964 }
4965 });
4966 let file_mtimes: Vec<_> = index
4967 .file_mtimes
4968 .iter()
4969 .filter_map(|(path, mtime)| {
4970 cache_relative_path(&index.project_root, path)
4971 .map(|relative| (relative, path, mtime))
4972 })
4973 .collect();
4974 let entries: Vec<_> = index
4975 .entries
4976 .iter()
4977 .filter_map(|entry| {
4978 cache_relative_path(&index.project_root, &entry.chunk.file)
4979 .map(|relative| (relative, entry))
4980 })
4981 .collect();
4982
4983 buf.push(SEMANTIC_INDEX_VERSION_V6);
4984 buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
4985 buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
4986 let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
4987 buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
4988 buf.extend_from_slice(fp_bytes_ref);
4989
4990 buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
4991 for (relative, path, mtime) in &file_mtimes {
4992 let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
4993 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
4994 buf.extend_from_slice(&path_bytes);
4995 let duration = mtime
4996 .duration_since(SystemTime::UNIX_EPOCH)
4997 .unwrap_or_default();
4998 buf.extend_from_slice(&duration.as_secs().to_le_bytes());
4999 buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
5000 let size = index.file_sizes.get(*path).copied().unwrap_or_default();
5001 buf.extend_from_slice(&size.to_le_bytes());
5002 let hash = index
5003 .file_hashes
5004 .get(*path)
5005 .copied()
5006 .unwrap_or_else(cache_freshness::zero_hash);
5007 buf.extend_from_slice(hash.as_bytes());
5008 }
5009
5010 for (relative, entry) in &entries {
5011 let c = &entry.chunk;
5012 let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
5013 buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
5014 buf.extend_from_slice(&file_bytes);
5015
5016 let name_bytes = c.name.as_bytes();
5017 buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
5018 buf.extend_from_slice(name_bytes);
5019
5020 buf.push(symbol_kind_to_u8(&c.kind));
5021 buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
5022 buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
5023 buf.push(c.exported as u8);
5024
5025 let snippet_bytes = c.snippet.as_bytes();
5026 buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
5027 buf.extend_from_slice(snippet_bytes);
5028
5029 let embed_bytes = c.embed_text.as_bytes();
5030 buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
5031 buf.extend_from_slice(embed_bytes);
5032
5033 for &val in &entry.vector {
5034 buf.extend_from_slice(&val.to_le_bytes());
5035 }
5036 }
5037
5038 buf
5039 }
5040
5041 #[derive(Default)]
5042 struct RecordingEmbedder {
5043 calls: Vec<Vec<String>>,
5044 }
5045
5046 impl RecordingEmbedder {
5047 fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
5048 let vectors = texts
5049 .iter()
5050 .map(|text| deterministic_test_vector(text))
5051 .collect();
5052 self.calls.push(texts);
5053 Ok(vectors)
5054 }
5055
5056 fn total_embedded_texts(&self) -> usize {
5057 self.calls.iter().map(Vec::len).sum()
5058 }
5059
5060 fn embedded_texts(&self) -> Vec<&str> {
5061 self.calls
5062 .iter()
5063 .flat_map(|batch| batch.iter().map(String::as_str))
5064 .collect()
5065 }
5066 }
5067
5068 fn deterministic_test_vector(text: &str) -> Vec<f32> {
5069 let hash = blake3::hash(text.as_bytes());
5070 let bytes = hash.as_bytes();
5071 vec![
5072 1.0,
5073 bytes[0] as f32 / 255.0,
5074 bytes[1] as f32 / 255.0,
5075 bytes[2] as f32 / 255.0,
5076 ]
5077 }
5078
5079 fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
5080 let mut embedder = RecordingEmbedder::default();
5081 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5082 SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
5083 }
5084
5085 fn force_stale(index: &mut SemanticIndex, file: &Path) {
5086 set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
5087 }
5088
5089 fn write_source(path: &Path, source: &str) {
5090 if let Some(parent) = path.parent() {
5091 fs::create_dir_all(parent).unwrap();
5092 }
5093 fs::write(path, source).unwrap();
5094 }
5095
5096 fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
5097 index
5098 .entries
5099 .iter()
5100 .filter(|entry| entry.chunk.file == file)
5101 .collect()
5102 }
5103
5104 fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
5105 index
5106 .entries
5107 .iter()
5108 .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
5109 .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
5110 }
5111
5112 fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
5113 index
5114 .entries
5115 .iter()
5116 .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
5117 .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
5118 }
5119
5120 #[test]
5121 fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
5122 let owner = tempfile::tempdir().unwrap();
5123 let storage = tempfile::tempdir().unwrap();
5124 let borrower_a = tempfile::tempdir().unwrap();
5125 let borrower_b = tempfile::tempdir().unwrap();
5126 let relative = Path::new("src/lib.rs");
5127 for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
5128 let file = root.join(relative);
5129 fs::create_dir_all(file.parent().unwrap()).unwrap();
5130 fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
5131 }
5132 let owner_file = owner.path().join(relative);
5133 let metadata = fs::metadata(&owner_file).unwrap();
5134 let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
5135 index.entries.push(EmbeddingEntry {
5136 chunk: SemanticChunk {
5137 file: owner_file.clone(),
5138 name: "shared_symbol".to_string(),
5139 qualified_name: None,
5140 kind: SymbolKind::Function,
5141 start_line: 0,
5142 end_line: 0,
5143 exported: true,
5144 embed_text: "shared symbol".to_string(),
5145 snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
5146 },
5147 norm: vector_norm(&[1.0, 0.0, 0.0]),
5148 vector: vec![1.0, 0.0, 0.0],
5149 });
5150 index.file_mtimes.insert(
5151 owner_file.clone(),
5152 metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
5153 );
5154 index.file_sizes.insert(owner_file.clone(), metadata.len());
5155 index.file_hashes.insert(
5156 owner_file,
5157 blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
5158 );
5159 index.set_fingerprint(SemanticIndexFingerprint {
5160 backend: "test".to_string(),
5161 model: "shared-base".to_string(),
5162 base_url: FALLBACK_BACKEND.to_string(),
5163 dimension: 3,
5164 chunking_version: default_chunking_version(),
5165 });
5166 assert!(index.shared_base.is_none(), "owner indexes stay private");
5167
5168 let project_key = format!(
5169 "shared-base-{}",
5170 blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
5171 );
5172 let dir = storage.path().join("semantic").join(&project_key);
5173 fs::create_dir_all(&dir).unwrap();
5174 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5175 let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
5176 let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
5177 let a = SemanticIndex::read_from_disk_borrow_tolerant(
5178 storage.path(),
5179 &project_key,
5180 borrower_a.path(),
5181 )
5182 .unwrap();
5183 let b = SemanticIndex::read_from_disk_borrow_tolerant(
5184 storage.path(),
5185 &project_key,
5186 borrower_b.path(),
5187 )
5188 .unwrap();
5189 let a_base = a.shared_base.as_ref().unwrap();
5190 let b_base = b.shared_base.as_ref().unwrap();
5191 assert!(Arc::ptr_eq(a_base, b_base));
5192 assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
5193 assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
5194 assert_eq!(
5195 a.search(&[1.0, 0.0, 0.0], 1)[0].file,
5196 borrower_a.path().join(relative)
5197 );
5198 assert_eq!(
5199 b.search(&[1.0, 0.0, 0.0], 1)[0].file,
5200 borrower_b.path().join(relative)
5201 );
5202 assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
5203 assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
5204
5205 let weak = Arc::downgrade(a_base);
5206 let ctx = crate::context::AppContext::new(
5207 Box::new(crate::parser::TreeSitterProvider::new()),
5208 crate::config::Config {
5209 project_root: Some(borrower_a.path().to_path_buf()),
5210 ..crate::config::Config::default()
5211 },
5212 );
5213 *ctx.semantic_index()
5214 .write()
5215 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
5216 assert!(ctx.evict_idle_artifacts());
5217 assert!(
5218 weak.upgrade().is_some(),
5219 "the second borrower keeps the base live"
5220 );
5221 drop(b);
5222 assert!(
5223 weak.upgrade().is_none(),
5224 "the last borrower releases the base"
5225 );
5226 }
5227
5228 #[test]
5229 fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
5230 let owner = tempfile::tempdir().unwrap();
5231 let storage = tempfile::tempdir().unwrap();
5232 let borrower_a = tempfile::tempdir().unwrap();
5233 let borrower_b = tempfile::tempdir().unwrap();
5234 let relative = Path::new("src/lib.rs");
5235 for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
5236 let file = root.join(relative);
5237 fs::create_dir_all(file.parent().unwrap()).unwrap();
5238 fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
5239 }
5240 let owner_file = owner.path().join(relative);
5241 let metadata = fs::metadata(&owner_file).unwrap();
5242 let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
5243 index.entries.push(EmbeddingEntry {
5244 chunk: SemanticChunk {
5245 file: owner_file.clone(),
5246 name: "hash_guard".to_string(),
5247 qualified_name: None,
5248 kind: SymbolKind::Function,
5249 start_line: 0,
5250 end_line: 0,
5251 exported: true,
5252 embed_text: "hash guard".to_string(),
5253 snippet: "pub fn hash_guard() {}".to_string(),
5254 },
5255 norm: vector_norm(&[1.0, 0.0]),
5256 vector: vec![1.0, 0.0],
5257 });
5258 index.file_mtimes.insert(
5259 owner_file.clone(),
5260 metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
5261 );
5262 index.file_sizes.insert(owner_file.clone(), metadata.len());
5263 index
5264 .file_hashes
5265 .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
5266 index.set_fingerprint(SemanticIndexFingerprint {
5267 backend: "test".to_string(),
5268 model: "hash-guard".to_string(),
5269 base_url: FALLBACK_BACKEND.to_string(),
5270 dimension: 2,
5271 chunking_version: default_chunking_version(),
5272 });
5273 let project_key = format!(
5274 "hash-fallback-{}",
5275 blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
5276 );
5277 let dir = storage.path().join("semantic").join(&project_key);
5278 fs::create_dir_all(&dir).unwrap();
5279 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5280 let shared = SemanticIndex::read_from_disk_borrow_tolerant(
5281 storage.path(),
5282 &project_key,
5283 borrower_a.path(),
5284 )
5285 .unwrap();
5286 assert!(shared.shared_base.is_some());
5287
5288 let changed_vector = vec![0.0, 1.0];
5289 index.entries[0].norm = vector_norm(&changed_vector);
5290 index.entries[0].vector = changed_vector;
5291 fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5292 let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
5293 storage.path(),
5294 &project_key,
5295 borrower_b.path(),
5296 )
5297 .unwrap();
5298 assert!(
5299 fallback.shared_base.is_none(),
5300 "a different byte identity must not join the live shared generation"
5301 );
5302 drop(shared);
5303 }
5304
5305 #[test]
5306 fn borrow_only_root_skips_semantic_lock_and_persist() {
5307 let project = tempfile::tempdir().expect("project");
5308 let source = project.path().join("lib.rs");
5309 write_rust_file(&source, "borrow_only_symbol");
5310 let project_key = "shared-artifact-key".to_string();
5311 let storage = tempfile::tempdir().expect("storage");
5312 crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
5313
5314 let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
5315 .expect("borrow-only lock downgrade");
5316 let cache_dir = storage.path().join("semantic").join(&project_key);
5317 assert!(!cache_dir.join("cache.lock").exists());
5318
5319 let index = build_test_index(project.path(), &[source]);
5320 index.write_to_disk(storage.path(), &project_key);
5321
5322 assert!(!cache_dir.join("semantic.bin").exists());
5323 assert!(!cache_dir.exists());
5324 }
5325
5326 #[test]
5327 fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
5328 let temp = tempfile::tempdir().unwrap();
5329 let project_root = temp.path();
5330 let file = project_root.join("src/lib.rs");
5331 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
5332 write_source(&file, original);
5333
5334 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5335 let original_entry_count = index.entries.len();
5336 let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
5337
5338 write_source(&file, &format!("\n{original}"));
5339 force_stale(&mut index, &file);
5340
5341 let mut embedder = RecordingEmbedder::default();
5342 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5343 let mut progress = |_done: usize, _total: usize| {};
5344 let summary = index
5345 .refresh_stale_files(
5346 project_root,
5347 std::slice::from_ref(&file),
5348 &mut embed,
5349 16,
5350 &mut progress,
5351 )
5352 .unwrap();
5353
5354 assert_eq!(summary.changed, 1);
5355 assert_eq!(embedder.total_embedded_texts(), 0);
5356 assert_eq!(index.entries.len(), original_entry_count);
5357 let shifted_alpha = entry_by_name(&index, &file, "alpha");
5358 assert_eq!(shifted_alpha.chunk.start_line, 1);
5359 assert_eq!(shifted_alpha.vector, original_alpha_vector);
5360 }
5361
5362 #[test]
5363 fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
5364 let temp = tempfile::tempdir().unwrap();
5365 let project_root = temp.path();
5366 let file = project_root.join("src/lib.rs");
5367 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
5368 write_source(&file, original);
5369
5370 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5371 let mut serving_index = worker_index.clone();
5372 let original_entry_count = worker_index.entries.len();
5373
5374 write_source(&file, &format!("\n{original}"));
5375
5376 let mut embedder = RecordingEmbedder::default();
5377 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5378 let mut progress = |_done: usize, _total: usize| {};
5379 let update = worker_index
5380 .refresh_invalidated_files(
5381 project_root,
5382 std::slice::from_ref(&file),
5383 &mut embed,
5384 16,
5385 100,
5386 &mut progress,
5387 )
5388 .unwrap();
5389
5390 assert_eq!(embedder.total_embedded_texts(), 0);
5391 assert_eq!(update.added_entries.len(), original_entry_count);
5392 assert_eq!(worker_index.entries.len(), original_entry_count);
5393
5394 serving_index.apply_refresh_update(
5395 update.added_entries,
5396 update.updated_metadata,
5397 &update.completed_paths,
5398 );
5399
5400 assert_eq!(serving_index.entries.len(), original_entry_count);
5401 assert_eq!(
5402 entries_for_file(&serving_index, &file).len(),
5403 original_entry_count
5404 );
5405 assert_eq!(
5406 entry_by_name(&serving_index, &file, "alpha")
5407 .chunk
5408 .start_line,
5409 1
5410 );
5411 }
5412
5413 #[test]
5414 fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
5415 let temp = tempfile::tempdir().unwrap();
5416 let project_root = temp.path();
5417 let file = project_root.join("src/lib.rs");
5418 write_source(
5419 &file,
5420 "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
5421 );
5422
5423 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5424 let original_entry_count = index.entries.len();
5425 let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
5426
5427 write_source(
5428 &file,
5429 "pub fn alpha() -> i32 {\n 10\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
5430 );
5431
5432 let mut embedder = RecordingEmbedder::default();
5433 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5434 let mut progress = |_done: usize, _total: usize| {};
5435 let update = index
5436 .refresh_invalidated_files(
5437 project_root,
5438 std::slice::from_ref(&file),
5439 &mut embed,
5440 16,
5441 100,
5442 &mut progress,
5443 )
5444 .unwrap();
5445
5446 assert_eq!(embedder.total_embedded_texts(), 1);
5447 assert!(embedder.embedded_texts()[0].contains("name:alpha"));
5448 assert_eq!(update.added_entries.len(), original_entry_count);
5449 assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
5450 }
5451
5452 #[test]
5453 fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
5454 let temp = tempfile::tempdir().unwrap();
5455 let project_root = temp.path();
5456 let file = project_root.join("src/dupe.js");
5457 let one_duplicate = "function duplicate() {\n return 1;\n}\n";
5458 write_source(&file, one_duplicate);
5459
5460 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5461 let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
5462
5463 write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
5464
5465 let mut embedder = RecordingEmbedder::default();
5466 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5467 let mut progress = |_done: usize, _total: usize| {};
5468 index
5469 .refresh_invalidated_files(
5470 project_root,
5471 std::slice::from_ref(&file),
5472 &mut embed,
5473 16,
5474 100,
5475 &mut progress,
5476 )
5477 .unwrap();
5478
5479 let duplicate_entries = index
5480 .entries
5481 .iter()
5482 .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
5483 .collect::<Vec<_>>();
5484 assert_eq!(duplicate_entries.len(), 2);
5485 assert_eq!(embedder.total_embedded_texts(), 0);
5486 assert_eq!(duplicate_entries[0].vector, original_vector);
5487 assert_eq!(duplicate_entries[1].vector, original_vector);
5488 }
5489
5490 #[test]
5491 fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
5492 let temp = tempfile::tempdir().unwrap();
5493 let project_root = temp.path();
5494 let file = project_root.join("src/lib.rs");
5495 write_source(
5496 &file,
5497 "//! module docs v1\n\npub fn alpha() -> i32 {\n 1\n}\n",
5498 );
5499
5500 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5501 let summary_before = file_summary_entry(&index, &file).vector.clone();
5502
5503 write_source(
5504 &file,
5505 "//! module docs v1\n\npub fn alpha() -> i32 {\n 2\n}\n",
5506 );
5507 let mut body_embedder = RecordingEmbedder::default();
5508 let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
5509 let mut progress = |_done: usize, _total: usize| {};
5510 index
5511 .refresh_invalidated_files(
5512 project_root,
5513 std::slice::from_ref(&file),
5514 &mut body_embed,
5515 16,
5516 100,
5517 &mut progress,
5518 )
5519 .unwrap();
5520 assert_eq!(body_embedder.total_embedded_texts(), 1);
5521 assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
5522 assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
5523
5524 write_source(
5525 &file,
5526 "//! module docs v2\n\npub fn alpha() -> i32 {\n 2\n}\n",
5527 );
5528 let mut doc_embedder = RecordingEmbedder::default();
5529 let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
5530 index
5531 .refresh_invalidated_files(
5532 project_root,
5533 std::slice::from_ref(&file),
5534 &mut doc_embed,
5535 16,
5536 100,
5537 &mut progress,
5538 )
5539 .unwrap();
5540
5541 assert_eq!(doc_embedder.total_embedded_texts(), 1);
5542 assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
5543 assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
5544 }
5545
5546 #[test]
5547 fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
5548 let temp = tempfile::tempdir().unwrap();
5549 let project_root = temp.path();
5550 let file = project_root.join("src/lib.rs");
5551 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
5552
5553 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5554 let mut serving_index = worker_index.clone();
5555 fs::remove_file(&file).unwrap();
5556
5557 let mut embedder = RecordingEmbedder::default();
5558 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5559 let mut progress = |_done: usize, _total: usize| {};
5560 let update = worker_index
5561 .refresh_invalidated_files(
5562 project_root,
5563 std::slice::from_ref(&file),
5564 &mut embed,
5565 16,
5566 100,
5567 &mut progress,
5568 )
5569 .unwrap();
5570
5571 assert_eq!(update.summary.deleted, 1);
5572 assert_eq!(embedder.total_embedded_texts(), 0);
5573 assert!(worker_index.entries.is_empty());
5574
5575 serving_index.apply_refresh_update(
5576 update.added_entries,
5577 update.updated_metadata,
5578 &update.completed_paths,
5579 );
5580 assert!(serving_index.entries.is_empty());
5581 }
5582
5583 #[test]
5584 fn watcher_collect_failure_does_not_resurrect_stale_entries() {
5585 let temp = tempfile::tempdir().unwrap();
5586 let project_root = temp.path();
5587 let file = project_root.join("src/lib.rs");
5588 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
5589
5590 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5591 let mut serving_index = worker_index.clone();
5592 fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
5593
5594 let mut embedder = RecordingEmbedder::default();
5595 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5596 let mut progress = |_done: usize, _total: usize| {};
5597 let update = worker_index
5598 .refresh_invalidated_files(
5599 project_root,
5600 std::slice::from_ref(&file),
5601 &mut embed,
5602 16,
5603 100,
5604 &mut progress,
5605 )
5606 .unwrap();
5607
5608 assert_eq!(embedder.total_embedded_texts(), 0);
5609 assert!(update.added_entries.is_empty());
5610 assert!(worker_index.entries.is_empty());
5611 assert!(!worker_index.file_mtimes.contains_key(&file));
5612
5613 serving_index.apply_refresh_update(
5614 update.added_entries,
5615 update.updated_metadata,
5616 &update.completed_paths,
5617 );
5618 assert!(serving_index.entries.is_empty());
5619 assert!(!serving_index.file_mtimes.contains_key(&file));
5620 }
5621
5622 #[test]
5623 fn refresh_invalidated_cap_deferral_remains_file_count_based() {
5624 let temp = tempfile::tempdir().unwrap();
5625 let project_root = temp.path();
5626 let indexed = project_root.join("src/a.rs");
5627 let deferred = project_root.join("src/b.rs");
5628 write_source(&indexed, "pub fn alpha() -> i32 {\n 1\n}\n");
5629 write_source(&deferred, "pub fn beta() -> i32 {\n 2\n}\n");
5630
5631 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
5632 let mut embedder = RecordingEmbedder::default();
5633 let mut embed = |texts: Vec<String>| embedder.embed(texts);
5634 let mut progress = |_done: usize, _total: usize| {};
5635 let update = index
5636 .refresh_invalidated_files(
5637 project_root,
5638 std::slice::from_ref(&deferred),
5639 &mut embed,
5640 16,
5641 1,
5642 &mut progress,
5643 )
5644 .unwrap();
5645
5646 assert_eq!(update.summary.total_processed, 1);
5647 assert_eq!(update.summary.added, 0);
5648 assert_eq!(embedder.total_embedded_texts(), 0);
5649 assert_eq!(index.indexed_file_count(), 1);
5650 assert!(index.deferred_files.contains(&deferred));
5651 assert!(entries_for_file(&index, &deferred).is_empty());
5652 }
5653
5654 #[test]
5655 fn semantic_cache_serialization_skips_paths_outside_project_root() {
5656 let dir = tempfile::tempdir().expect("create temp dir");
5657 let project = fs::canonicalize(dir.path()).expect("canonical project");
5658 let outside = project.join("..").join("outside.rs");
5659 let mut index = SemanticIndex::new(project.clone(), 3);
5660 index
5661 .file_mtimes
5662 .insert(outside.clone(), SystemTime::UNIX_EPOCH);
5663 index.file_sizes.insert(outside.clone(), 1);
5664 index
5665 .file_hashes
5666 .insert(outside.clone(), cache_freshness::zero_hash());
5667 index.entries.push(EmbeddingEntry {
5668 chunk: SemanticChunk {
5669 file: outside,
5670 name: "outside".to_string(),
5671 qualified_name: None,
5672 kind: SymbolKind::Function,
5673 start_line: 0,
5674 end_line: 0,
5675 exported: false,
5676 embed_text: "outside".to_string(),
5677 snippet: "outside".to_string(),
5678 },
5679 norm: vector_norm(&[1.0, 0.0, 0.0]),
5680 vector: vec![1.0, 0.0, 0.0],
5681 });
5682
5683 let bytes = index.to_bytes();
5684 let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
5685 assert_eq!(loaded.entries.len(), 0);
5686 assert!(loaded.file_mtimes.is_empty());
5687 }
5688
5689 #[test]
5690 fn semantic_search_bounded_top_k_matches_reference_full_sort() {
5691 let project_root = test_project_root();
5692 let file = project_root.join("src/lib.rs");
5693 let mut index = SemanticIndex::new(project_root, 2);
5694 let entries = [
5695 ("alpha", vec![2.0, 0.0], false),
5696 ("beta", vec![0.0, 3.0], false),
5697 ("gamma", vec![4.0, 0.0], false),
5698 ("delta", vec![1.0, 1.0], true),
5699 ("epsilon", vec![-5.0, 0.0], false),
5700 ];
5701 for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
5702 index.entries.push(EmbeddingEntry {
5703 chunk: SemanticChunk {
5704 file: file.clone(),
5705 name: name.to_string(),
5706 qualified_name: None,
5707 kind: SymbolKind::Function,
5708 start_line: line as u32 + 1,
5709 end_line: line as u32 + 1,
5710 exported,
5711 embed_text: name.to_string(),
5712 snippet: format!("fn {name}() {{}}"),
5713 },
5714 norm: vector_norm(&vector),
5715 vector,
5716 });
5717 }
5718
5719 let query = vec![2.0, 0.0];
5720 let top_k = 4;
5721 let mut reference: Vec<(f32, usize)> = index
5722 .entries
5723 .iter()
5724 .enumerate()
5725 .map(|(idx, entry)| {
5726 let mut dot = 0.0f32;
5729 let mut query_squared_norm = 0.0f32;
5730 let mut entry_squared_norm = 0.0f32;
5731 for i in 0..query.len() {
5732 dot += query[i] * entry.vector[i];
5733 query_squared_norm += query[i] * query[i];
5734 entry_squared_norm += entry.vector[i] * entry.vector[i];
5735 }
5736 let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
5737 let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
5738 if entry.chunk.exported {
5739 score *= 1.1;
5740 }
5741 (score, idx)
5742 })
5743 .collect();
5744 reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
5745 let expected: Vec<(String, f32)> = reference
5746 .into_iter()
5747 .take(top_k)
5748 .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
5749 .collect();
5750
5751 let actual: Vec<(String, f32)> = index
5752 .search(&query, top_k)
5753 .into_iter()
5754 .map(|result| (result.name, result.score))
5755 .collect();
5756
5757 assert_eq!(
5758 actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
5759 expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
5760 );
5761 for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
5762 assert!((actual_score - expected_score).abs() < 1e-6);
5763 }
5764 assert_eq!(actual[0].0, "alpha");
5765 assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
5766 assert!(index.search(&query, 0).is_empty());
5767 }
5768
5769 #[test]
5770 fn test_cosine_similarity_identical() {
5771 let a = vec![1.0, 0.0, 0.0];
5772 let b = vec![1.0, 0.0, 0.0];
5773 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
5774 }
5775
5776 #[test]
5777 fn test_cosine_similarity_orthogonal() {
5778 let a = vec![1.0, 0.0, 0.0];
5779 let b = vec![0.0, 1.0, 0.0];
5780 assert!(cosine_similarity(&a, &b).abs() < 0.001);
5781 }
5782
5783 #[test]
5784 fn test_cosine_similarity_opposite() {
5785 let a = vec![1.0, 0.0, 0.0];
5786 let b = vec![-1.0, 0.0, 0.0];
5787 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
5788 }
5789
5790 #[test]
5791 fn test_serialization_roundtrip() {
5792 let project_root = test_project_root();
5793 let file = project_root.join("src/main.rs");
5794 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
5795 index.entries.push(EmbeddingEntry {
5796 chunk: SemanticChunk {
5797 file: file.clone(),
5798 name: "handle_request".to_string(),
5799 qualified_name: None,
5800 kind: SymbolKind::Function,
5801 start_line: 10,
5802 end_line: 25,
5803 exported: true,
5804 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
5805 snippet: "fn handle_request() {\n // ...\n}".to_string(),
5806 },
5807 norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
5808 vector: vec![0.1, 0.2, 0.3, 0.4],
5809 });
5810 index.dimension = 4;
5811 index
5812 .file_mtimes
5813 .insert(file.clone(), SystemTime::UNIX_EPOCH);
5814 index.file_sizes.insert(file, 0);
5815 index.set_fingerprint(SemanticIndexFingerprint {
5816 backend: "fastembed".to_string(),
5817 model: "all-MiniLM-L6-v2".to_string(),
5818 base_url: FALLBACK_BACKEND.to_string(),
5819 dimension: 4,
5820 chunking_version: default_chunking_version(),
5821 });
5822
5823 let bytes = index.to_bytes();
5824 let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
5825
5826 assert_eq!(restored.entries.len(), 1);
5827 assert_eq!(restored.entries[0].chunk.name, "handle_request");
5828 assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
5829 assert_eq!(
5830 restored.entries[0].norm,
5831 vector_norm(&restored.entries[0].vector)
5832 );
5833 assert_eq!(restored.dimension, 4);
5834 assert_eq!(restored.backend_label(), Some("fastembed"));
5835 assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
5836 }
5837
5838 #[test]
5839 fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
5840 let storage = tempfile::tempdir().expect("create storage dir");
5841 let project = storage.path().join("project");
5842 fs::create_dir_all(project.join("src")).expect("create project src");
5843 let file = project.join("src/lib.rs");
5844 fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
5845 let project_root = fs::canonicalize(&project).expect("canonical project");
5846 let file = fs::canonicalize(&file).expect("canonical file");
5847
5848 let mut index = SemanticIndex::new(project_root.clone(), 3);
5849 let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
5850 index.file_mtimes.insert(file.clone(), mtime);
5851 index.file_sizes.insert(file.clone(), 42);
5852 index
5853 .file_hashes
5854 .insert(file.clone(), cache_freshness::zero_hash());
5855 index.entries.push(EmbeddingEntry {
5856 chunk: SemanticChunk {
5857 file: file.clone(),
5858 name: "alpha".to_string(),
5859 qualified_name: Some("Service.alpha".to_string()),
5860 kind: SymbolKind::Function,
5861 start_line: 0,
5862 end_line: 0,
5863 exported: true,
5864 embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
5865 snippet: "pub fn alpha() {}".to_string(),
5866 },
5867 norm: vector_norm(&[0.1, 0.2, 0.3]),
5868 vector: vec![0.1, 0.2, 0.3],
5869 });
5870 index.entries.push(EmbeddingEntry {
5871 chunk: SemanticChunk {
5872 file: file.clone(),
5873 name: "beta".to_string(),
5874 qualified_name: Some("Service.beta".to_string()),
5875 kind: SymbolKind::Function,
5876 start_line: 1,
5877 end_line: 1,
5878 exported: true,
5879 embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
5880 snippet: "pub fn beta() {}".to_string(),
5881 },
5882 norm: vector_norm(&[0.4, 0.5, 0.6]),
5883 vector: vec![0.4, 0.5, 0.6],
5884 });
5885 let fingerprint = SemanticIndexFingerprint {
5886 backend: "fastembed".to_string(),
5887 model: "all-MiniLM-L6-v2".to_string(),
5888 base_url: FALLBACK_BACKEND.to_string(),
5889 dimension: 3,
5890 chunking_version: default_chunking_version(),
5891 };
5892 let fingerprint_before = fingerprint.as_string();
5893 index.set_fingerprint(fingerprint.clone());
5894
5895 let legacy_bytes = legacy_semantic_index_bytes(&index);
5896 assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
5897 let legacy_dir = storage.path().join("semantic/legacy-proj");
5898 fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
5899 let legacy_path = legacy_dir.join("semantic.bin");
5900 fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
5901 let legacy_loaded = SemanticIndex::read_from_disk(
5902 storage.path(),
5903 "legacy-proj",
5904 &project_root,
5905 false,
5906 Some(&fingerprint_before),
5907 )
5908 .expect("load v6 semantic index");
5909 assert!(
5910 legacy_path.exists(),
5911 "compatible V6 cache must not be deleted"
5912 );
5913 assert!(legacy_loaded
5914 .entries
5915 .iter()
5916 .all(|entry| entry.chunk.qualified_name.is_none()));
5917 assert_eq!(
5918 legacy_loaded.fingerprint().unwrap().as_string(),
5919 fingerprint_before
5920 );
5921
5922 let v7_bytes = index.to_bytes();
5923 assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
5924 assert_ne!(v7_bytes, legacy_bytes);
5925 let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
5926 assert_eq!(
5927 restored.entries[0].chunk.qualified_name.as_deref(),
5928 Some("Service.alpha")
5929 );
5930 assert_eq!(
5931 restored.entries[1].chunk.qualified_name.as_deref(),
5932 Some("Service.beta")
5933 );
5934 assert_eq!(
5935 restored.fingerprint().unwrap().as_string(),
5936 fingerprint_before
5937 );
5938
5939 index.write_to_disk(storage.path(), "proj");
5940 let data_path = storage.path().join("semantic/proj/semantic.bin");
5941 let persisted = fs::read(&data_path).expect("read semantic.bin");
5942 assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
5943
5944 let loaded = SemanticIndex::read_from_disk(
5945 storage.path(),
5946 "proj",
5947 &project_root,
5948 false,
5949 Some(&fingerprint_before),
5950 )
5951 .expect("load semantic index");
5952 assert_eq!(loaded.entries.len(), index.entries.len());
5953 assert_eq!(loaded.dimension, index.dimension);
5954 assert_eq!(
5955 loaded.fingerprint().unwrap().as_string(),
5956 fingerprint_before
5957 );
5958 assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
5959 assert_eq!(loaded.file_sizes.get(&file), Some(&42));
5960 assert_eq!(
5961 loaded.file_hashes.get(&file),
5962 Some(&cache_freshness::zero_hash())
5963 );
5964 for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
5965 assert_eq!(actual.chunk.file, expected.chunk.file);
5966 assert_eq!(actual.chunk.name, expected.chunk.name);
5967 assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
5968 assert_eq!(actual.chunk.kind, expected.chunk.kind);
5969 assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
5970 assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
5971 assert_eq!(actual.chunk.exported, expected.chunk.exported);
5972 assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
5973 assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
5974 assert_eq!(actual.vector, expected.vector);
5975 }
5976 assert_eq!(loaded.to_bytes(), persisted);
5977 assert_eq!(fingerprint.as_string(), fingerprint_before);
5978 }
5979
5980 #[test]
5981 fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
5982 let cases = [
5983 (SymbolKind::Function, 0),
5984 (SymbolKind::Class, 1),
5985 (SymbolKind::Method, 2),
5986 (SymbolKind::Struct, 3),
5987 (SymbolKind::Interface, 4),
5988 (SymbolKind::Enum, 5),
5989 (SymbolKind::TypeAlias, 6),
5990 (SymbolKind::Variable, 7),
5991 (SymbolKind::Heading, 8),
5992 (SymbolKind::FileSummary, 9),
5993 ];
5994
5995 for (kind, encoded) in cases {
5996 assert_eq!(symbol_kind_to_u8(&kind), encoded);
5997 assert_eq!(u8_to_symbol_kind(encoded), kind);
5998 }
5999 }
6000
6001 #[test]
6002 fn test_search_top_k() {
6003 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6004 index.dimension = 3;
6005
6006 for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
6008 let mut vec = vec![0.0f32; 3];
6009 vec[i] = 1.0; index.entries.push(EmbeddingEntry {
6011 chunk: SemanticChunk {
6012 file: PathBuf::from("/src/lib.rs"),
6013 name: name.to_string(),
6014 qualified_name: None,
6015 kind: SymbolKind::Function,
6016 start_line: (i * 10 + 1) as u32,
6017 end_line: (i * 10 + 5) as u32,
6018 exported: true,
6019 embed_text: format!("kind:function name:{}", name),
6020 snippet: format!("fn {}() {{}}", name),
6021 },
6022 norm: vector_norm(&vec),
6023 vector: vec,
6024 });
6025 }
6026
6027 let query = vec![0.9, 0.1, 0.0];
6029 let results = index.search(&query, 2);
6030
6031 assert_eq!(results.len(), 2);
6032 assert_eq!(results[0].name, "auth"); assert!(results[0].score > results[1].score);
6034 }
6035
6036 #[test]
6037 fn test_empty_index_search() {
6038 let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6039 let results = index.search(&[0.1, 0.2, 0.3], 10);
6040 assert!(results.is_empty());
6041 }
6042
6043 #[test]
6044 fn single_line_symbol_builds_non_empty_snippet() {
6045 let symbol = Symbol {
6046 name: "answer".to_string(),
6047 kind: SymbolKind::Variable,
6048 range: crate::symbols::Range {
6049 start_line: 0,
6050 start_col: 0,
6051 end_line: 0,
6052 end_col: 24,
6053 },
6054 signature: Some("const answer = 42".to_string()),
6055 scope_chain: Vec::new(),
6056 exported: true,
6057 parent: None,
6058 };
6059 let source = "export const answer = 42;\n";
6060
6061 let snippet = build_snippet(&symbol, source);
6062
6063 assert_eq!(snippet, "export const answer = 42;");
6064 }
6065
6066 #[test]
6067 fn optimized_file_chunk_collection_matches_file_parser_path() {
6068 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
6069 let file = project_root.join("src/semantic_index.rs");
6070 let source = std::fs::read_to_string(&file).unwrap();
6071
6072 let mut legacy_parser = FileParser::new();
6073 let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
6074 let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
6075
6076 let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
6077
6078 assert_eq!(
6079 chunk_fingerprint(&optimized_chunks),
6080 chunk_fingerprint(&legacy_chunks)
6081 );
6082 }
6083
6084 #[test]
6085 fn collect_file_chunks_indexes_java_symbols() {
6086 let dir = tempfile::tempdir().unwrap();
6087 let file = dir.path().join("Greeter.java");
6088 std::fs::write(
6089 &file,
6090 r#"package example;
6091
6092public class Greeter {
6093 public String greet(String name) {
6094 return "Hello, " + name;
6095 }
6096}
6097"#,
6098 )
6099 .unwrap();
6100
6101 let chunks = collect_file_chunks(dir.path(), &file).unwrap();
6102
6103 assert!(
6104 !chunks.is_empty(),
6105 "Java file should produce semantic chunks"
6106 );
6107 assert!(
6108 chunks
6109 .iter()
6110 .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
6111 "Java class symbol should be chunked: {chunks:?}"
6112 );
6113 assert!(
6114 chunks
6115 .iter()
6116 .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
6117 "Java method symbol should be chunked: {chunks:?}"
6118 );
6119 }
6120
6121 fn chunk_fingerprint(
6122 chunks: &[SemanticChunk],
6123 ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
6124 chunks
6125 .iter()
6126 .map(|chunk| {
6127 (
6128 chunk.name.clone(),
6129 chunk.kind.clone(),
6130 chunk.start_line,
6131 chunk.end_line,
6132 chunk.exported,
6133 chunk.embed_text.clone(),
6134 chunk.snippet.clone(),
6135 )
6136 })
6137 .collect()
6138 }
6139
6140 #[test]
6141 fn collect_file_chunks_skips_oversized_file() {
6142 let dir = tempfile::tempdir().unwrap();
6143 let big = dir.path().join("huge.ts");
6144 let filler = "export const x = 1;\n"
6146 .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
6147 std::fs::write(&big, &filler).unwrap();
6148 assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
6149
6150 let chunks = collect_file_chunks(dir.path(), &big).unwrap();
6153 assert!(chunks.is_empty(), "oversized file must yield no chunks");
6154
6155 let small = dir.path().join("small.ts");
6157 std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
6158 let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
6159 assert!(!small_chunks.is_empty(), "small file should still chunk");
6160 }
6161
6162 #[test]
6163 fn rejects_oversized_dimension_during_deserialization() {
6164 let mut bytes = Vec::new();
6165 bytes.push(1u8);
6166 bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
6167 bytes.extend_from_slice(&0u32.to_le_bytes());
6168 bytes.extend_from_slice(&0u32.to_le_bytes());
6169
6170 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6171 }
6172
6173 #[test]
6174 fn rejects_oversized_entry_count_during_deserialization() {
6175 let mut bytes = Vec::new();
6176 bytes.push(1u8);
6177 bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
6178 bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
6179 bytes.extend_from_slice(&0u32.to_le_bytes());
6180
6181 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6182 }
6183
6184 #[test]
6185 fn invalidate_file_removes_entries_and_mtime() {
6186 let target = PathBuf::from("/src/main.rs");
6187 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6188 index.entries.push(EmbeddingEntry {
6189 chunk: SemanticChunk {
6190 file: target.clone(),
6191 name: "main".to_string(),
6192 qualified_name: None,
6193 kind: SymbolKind::Function,
6194 start_line: 0,
6195 end_line: 1,
6196 exported: false,
6197 embed_text: "main".to_string(),
6198 snippet: "fn main() {}".to_string(),
6199 },
6200 norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
6201 vector: vec![1.0; DEFAULT_DIMENSION],
6202 });
6203 index
6204 .file_mtimes
6205 .insert(target.clone(), SystemTime::UNIX_EPOCH);
6206 index.file_sizes.insert(target.clone(), 0);
6207
6208 index.invalidate_file(&target);
6209
6210 assert!(index.entries.is_empty());
6211 assert!(!index.file_mtimes.contains_key(&target));
6212 assert!(!index.file_sizes.contains_key(&target));
6213 }
6214
6215 #[test]
6216 fn refresh_missing_changed_file_is_purged_after_collect() {
6217 let temp = tempfile::tempdir().unwrap();
6218 let project_root = temp.path();
6219 let file = project_root.join("src/lib.rs");
6220 fs::create_dir_all(file.parent().unwrap()).unwrap();
6221 write_rust_file(&file, "vanished_symbol");
6222
6223 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6224 let original_size = *index.file_sizes.get(&file).unwrap();
6225 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
6226 fs::remove_file(&file).unwrap();
6227
6228 let mut embed = test_vector_for_texts;
6229 let mut progress = |_done: usize, _total: usize| {};
6230 let summary = index
6231 .refresh_stale_files(
6232 project_root,
6233 std::slice::from_ref(&file),
6234 &mut embed,
6235 8,
6236 &mut progress,
6237 )
6238 .unwrap();
6239
6240 assert_eq!(summary.changed, 0);
6241 assert_eq!(summary.added, 0);
6242 assert_eq!(summary.deleted, 1);
6243 assert!(index.entries.is_empty());
6244 assert!(!index.file_mtimes.contains_key(&file));
6245 assert!(!index.file_sizes.contains_key(&file));
6246 assert!(!index.file_hashes.contains_key(&file));
6247 }
6248
6249 #[test]
6250 fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
6251 let temp = tempfile::tempdir().unwrap();
6252 let project_root = temp.path();
6253 let file = project_root.join("src/lib.rs");
6254 fs::create_dir_all(file.parent().unwrap()).unwrap();
6255 write_rust_file(&file, "kept_symbol");
6256
6257 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6258 let original_entry_count = index.entries.len();
6259 let original_mtime = *index.file_mtimes.get(&file).unwrap();
6260 let original_size = *index.file_sizes.get(&file).unwrap();
6261
6262 let stale_mtime = SystemTime::UNIX_EPOCH;
6263 set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
6264 fs::remove_file(&file).unwrap();
6265 fs::create_dir(&file).unwrap();
6266
6267 let mut embed = test_vector_for_texts;
6268 let mut progress = |_done: usize, _total: usize| {};
6269 let summary = index
6270 .refresh_stale_files(
6271 project_root,
6272 std::slice::from_ref(&file),
6273 &mut embed,
6274 8,
6275 &mut progress,
6276 )
6277 .unwrap();
6278
6279 assert_eq!(summary.changed, 0);
6280 assert_eq!(summary.added, 0);
6281 assert_eq!(summary.deleted, 0);
6282 assert_eq!(index.entries.len(), original_entry_count);
6283 assert!(index
6284 .entries
6285 .iter()
6286 .any(|entry| entry.chunk.name == "kept_symbol"));
6287 assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
6288 assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
6289 assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
6290 }
6291
6292 #[test]
6293 fn refresh_never_indexed_file_error_does_not_record_mtime() {
6294 let temp = tempfile::tempdir().unwrap();
6295 let project_root = temp.path();
6296 let missing = project_root.join("src/missing.rs");
6297 fs::create_dir_all(missing.parent().unwrap()).unwrap();
6298
6299 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6300 let mut embed = test_vector_for_texts;
6301 let mut progress = |_done: usize, _total: usize| {};
6302 let summary = index
6303 .refresh_stale_files(
6304 project_root,
6305 std::slice::from_ref(&missing),
6306 &mut embed,
6307 8,
6308 &mut progress,
6309 )
6310 .unwrap();
6311
6312 assert_eq!(summary.added, 0);
6313 assert_eq!(summary.changed, 0);
6314 assert_eq!(summary.deleted, 0);
6315 assert!(!index.file_mtimes.contains_key(&missing));
6316 assert!(!index.file_sizes.contains_key(&missing));
6317 assert!(index.entries.is_empty());
6318 }
6319
6320 #[test]
6321 fn refresh_reports_added_for_new_files() {
6322 let temp = tempfile::tempdir().unwrap();
6323 let project_root = temp.path();
6324 let existing = project_root.join("src/lib.rs");
6325 let added = project_root.join("src/new.rs");
6326 fs::create_dir_all(existing.parent().unwrap()).unwrap();
6327 write_rust_file(&existing, "existing_symbol");
6328 write_rust_file(&added, "added_symbol");
6329
6330 let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
6331 let mut embed = test_vector_for_texts;
6332 let mut progress = |_done: usize, _total: usize| {};
6333 let summary = index
6334 .refresh_stale_files(
6335 project_root,
6336 &[existing.clone(), added.clone()],
6337 &mut embed,
6338 8,
6339 &mut progress,
6340 )
6341 .unwrap();
6342
6343 assert_eq!(summary.added, 1);
6344 assert_eq!(summary.changed, 0);
6345 assert_eq!(summary.deleted, 0);
6346 assert_eq!(summary.total_processed, 2);
6347 assert!(index.file_mtimes.contains_key(&added));
6348 assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
6349 }
6350
6351 #[test]
6352 fn refresh_reports_deleted_for_removed_files() {
6353 let temp = tempfile::tempdir().unwrap();
6354 let project_root = temp.path();
6355 let deleted = project_root.join("src/deleted.rs");
6356 fs::create_dir_all(deleted.parent().unwrap()).unwrap();
6357 write_rust_file(&deleted, "deleted_symbol");
6358
6359 let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
6360 fs::remove_file(&deleted).unwrap();
6361
6362 let mut embed = test_vector_for_texts;
6363 let mut progress = |_done: usize, _total: usize| {};
6364 let summary = index
6365 .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
6366 .unwrap();
6367
6368 assert_eq!(summary.deleted, 1);
6369 assert_eq!(summary.changed, 0);
6370 assert_eq!(summary.added, 0);
6371 assert_eq!(summary.total_processed, 1);
6372 assert!(!index.file_mtimes.contains_key(&deleted));
6373 assert!(index.entries.is_empty());
6374 }
6375
6376 #[test]
6377 fn refresh_reports_changed_for_modified_files() {
6378 let temp = tempfile::tempdir().unwrap();
6379 let project_root = temp.path();
6380 let file = project_root.join("src/lib.rs");
6381 fs::create_dir_all(file.parent().unwrap()).unwrap();
6382 write_rust_file(&file, "old_symbol");
6383
6384 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6385 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
6386 write_rust_file(&file, "new_symbol");
6387
6388 let mut embed = test_vector_for_texts;
6389 let mut progress = |_done: usize, _total: usize| {};
6390 let summary = index
6391 .refresh_stale_files(
6392 project_root,
6393 std::slice::from_ref(&file),
6394 &mut embed,
6395 8,
6396 &mut progress,
6397 )
6398 .unwrap();
6399
6400 assert_eq!(summary.changed, 1);
6401 assert_eq!(summary.added, 0);
6402 assert_eq!(summary.deleted, 0);
6403 assert_eq!(summary.total_processed, 1);
6404 assert!(index
6405 .entries
6406 .iter()
6407 .any(|entry| entry.chunk.name == "new_symbol"));
6408 assert!(!index
6409 .entries
6410 .iter()
6411 .any(|entry| entry.chunk.name == "old_symbol"));
6412 }
6413
6414 #[test]
6415 fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
6416 let temp = tempfile::tempdir().unwrap();
6417 let project_root = temp.path();
6418 let file = project_root.join("src/lib.rs");
6419 fs::create_dir_all(file.parent().unwrap()).unwrap();
6420 write_rust_file(&file, "clean_symbol");
6421
6422 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6423 let original_entries = index.entries.len();
6424 let mut embed_called = false;
6425 let mut embed = |texts: Vec<String>| {
6426 embed_called = true;
6427 test_vector_for_texts(texts)
6428 };
6429 let mut progress = |_done: usize, _total: usize| {};
6430 let summary = index
6431 .refresh_stale_files(
6432 project_root,
6433 std::slice::from_ref(&file),
6434 &mut embed,
6435 8,
6436 &mut progress,
6437 )
6438 .unwrap();
6439
6440 assert!(summary.is_noop());
6441 assert_eq!(summary.total_processed, 1);
6442 assert!(!embed_called);
6443 assert_eq!(index.entries.len(), original_entries);
6444 }
6445
6446 #[test]
6447 fn detects_missing_onnx_runtime_from_dynamic_load_error() {
6448 let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
6449
6450 assert!(is_onnx_runtime_unavailable(message));
6451 }
6452
6453 #[test]
6454 fn formats_missing_onnx_runtime_with_install_hint() {
6455 let message = format_embedding_init_error(
6456 "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
6457 );
6458
6459 assert!(message.starts_with("ONNX Runtime not found. Install via:"));
6460 assert!(message.contains("Original error:"));
6461 }
6462
6463 #[test]
6464 fn interactive_query_budget_is_independent_from_build_timeout() {
6465 let mut config = SemanticBackendConfig {
6466 backend: SemanticBackend::OpenAiCompatible,
6467 model: "test-embedding".to_string(),
6468 base_url: Some("http://127.0.0.1:9".to_string()),
6469 api_key_env: None,
6470 timeout_ms: 0,
6471 query_timeout_ms: 0,
6472 max_batch_size: 64,
6473 max_files: 20_000,
6474 };
6475
6476 let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
6477 let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
6478 assert_eq!(
6479 build_model.timeout_ms(),
6480 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
6481 "background build keeps the longer default embedding timeout"
6482 );
6483 assert_eq!(
6484 query_model.timeout_ms(),
6485 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
6486 "a query-created model remains safe for later background build reuse"
6487 );
6488 assert_eq!(
6489 QueryBudget::from_config(&config).timeout_ms(),
6490 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
6491 );
6492
6493 config.timeout_ms = 60_000;
6494 assert_eq!(
6495 QueryBudget::from_config(&config).timeout_ms(),
6496 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6497 "the build timeout must not affect interactive requests"
6498 );
6499
6500 config.query_timeout_ms = 700;
6501 assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
6502 }
6503
6504 #[test]
6505 fn background_build_embedding_keeps_retry_ladder() {
6506 let (base_url, requests, handle) =
6507 start_slow_embedding_server(EMBEDDING_REQUEST_MAX_ATTEMPTS, Duration::from_millis(300));
6508 let config = SemanticBackendConfig {
6509 backend: SemanticBackend::OpenAiCompatible,
6510 model: "test-embedding".to_string(),
6511 base_url: Some(base_url),
6512 api_key_env: None,
6513 timeout_ms: 100,
6514 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6515 max_batch_size: 64,
6516 max_files: 20_000,
6517 };
6518 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6519
6520 let error = model
6521 .embed(vec!["slow build batch".to_string()])
6522 .expect_err("all slow build attempts should time out");
6523 handle.join().expect("slow embedding server");
6524
6525 assert!(embedding_failure_is_transient(&error), "error: {error}");
6526 assert_eq!(
6527 requests.load(Ordering::SeqCst),
6528 EMBEDDING_REQUEST_MAX_ATTEMPTS,
6529 "background builds must retain the existing retry ladder"
6530 );
6531 }
6532
6533 #[test]
6534 fn openai_compatible_backend_embeds_with_mock_server() {
6535 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
6536 assert!(request_line.starts_with("POST "));
6537 assert_eq!(path, "/v1/embeddings");
6538 "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
6539 });
6540
6541 let config = SemanticBackendConfig {
6542 backend: SemanticBackend::OpenAiCompatible,
6543 model: "test-embedding".to_string(),
6544 base_url: Some(base_url),
6545 api_key_env: None,
6546 timeout_ms: 5_000,
6547 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6548 max_batch_size: 64,
6549 max_files: 20_000,
6550 };
6551
6552 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6553 let vectors = model
6554 .embed(vec!["hello".to_string(), "world".to_string()])
6555 .unwrap();
6556
6557 assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
6558 handle.join().unwrap();
6559 }
6560
6561 #[test]
6571 fn openai_compatible_request_has_single_content_type_header() {
6572 use std::sync::{Arc, Mutex};
6573 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
6574 let captured_for_thread = Arc::clone(&captured);
6575
6576 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
6577 let addr = listener.local_addr().expect("local addr");
6578 let handle = thread::spawn(move || {
6579 let (mut stream, _) = listener.accept().expect("accept");
6580 let mut buf = Vec::new();
6581 let mut chunk = [0u8; 4096];
6582 let mut header_end = None;
6583 let mut content_length = 0usize;
6584 loop {
6585 let n = stream.read(&mut chunk).expect("read");
6586 if n == 0 {
6587 break;
6588 }
6589 buf.extend_from_slice(&chunk[..n]);
6590 if header_end.is_none() {
6591 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
6592 header_end = Some(pos + 4);
6593 for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
6594 if let Some(value) = line.strip_prefix("Content-Length:") {
6595 content_length = value.trim().parse::<usize>().unwrap_or(0);
6596 }
6597 }
6598 }
6599 }
6600 if let Some(end) = header_end {
6601 if buf.len() >= end + content_length {
6602 break;
6603 }
6604 }
6605 }
6606 *captured_for_thread.lock().unwrap() = buf;
6607 let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
6608 let response = format!(
6609 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
6610 body.len(),
6611 body
6612 );
6613 let _ = stream.write_all(response.as_bytes());
6614 });
6615
6616 let config = SemanticBackendConfig {
6617 backend: SemanticBackend::OpenAiCompatible,
6618 model: "text-embedding-3-small".to_string(),
6619 base_url: Some(format!("http://{}", addr)),
6620 api_key_env: None,
6621 timeout_ms: 5_000,
6622 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6623 max_batch_size: 64,
6624 max_files: 20_000,
6625 };
6626 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6627 let _ = model.embed(vec!["probe".to_string()]).unwrap();
6628 handle.join().unwrap();
6629
6630 let bytes = captured.lock().unwrap().clone();
6631 let request = String::from_utf8_lossy(&bytes);
6632
6633 let content_type_lines = request
6636 .lines()
6637 .filter(|line| {
6638 let lower = line.to_ascii_lowercase();
6639 lower.starts_with("content-type:")
6640 })
6641 .count();
6642 assert_eq!(
6643 content_type_lines, 1,
6644 "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
6645 );
6646
6647 assert!(
6650 request.contains(r#""model":"text-embedding-3-small""#),
6651 "request body should contain model field; full request:\n{request}",
6652 );
6653 }
6654
6655 #[test]
6656 fn ollama_backend_embeds_with_mock_server() {
6657 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
6658 assert!(request_line.starts_with("POST "));
6659 assert_eq!(path, "/api/embed");
6660 "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
6661 });
6662
6663 let config = SemanticBackendConfig {
6664 backend: SemanticBackend::Ollama,
6665 model: "embeddinggemma".to_string(),
6666 base_url: Some(base_url),
6667 api_key_env: None,
6668 timeout_ms: 5_000,
6669 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6670 max_batch_size: 64,
6671 max_files: 20_000,
6672 };
6673
6674 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6675 let vectors = model
6676 .embed(vec!["hello".to_string(), "world".to_string()])
6677 .unwrap();
6678
6679 assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
6680 handle.join().unwrap();
6681 }
6682
6683 #[test]
6684 fn read_from_disk_rejects_fingerprint_mismatch() {
6685 let storage = tempfile::tempdir().unwrap();
6686 let project_key = "proj";
6687
6688 let project_root = test_project_root();
6689 let file = project_root.join("src/main.rs");
6690 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
6691 index.entries.push(EmbeddingEntry {
6692 chunk: SemanticChunk {
6693 file: file.clone(),
6694 name: "handle_request".to_string(),
6695 qualified_name: None,
6696 kind: SymbolKind::Function,
6697 start_line: 10,
6698 end_line: 25,
6699 exported: true,
6700 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6701 snippet: "fn handle_request() {}".to_string(),
6702 },
6703 norm: vector_norm(&[0.1, 0.2, 0.3]),
6704 vector: vec![0.1, 0.2, 0.3],
6705 });
6706 index.dimension = 3;
6707 index
6708 .file_mtimes
6709 .insert(file.clone(), SystemTime::UNIX_EPOCH);
6710 index.file_sizes.insert(file, 0);
6711 index.set_fingerprint(SemanticIndexFingerprint {
6712 backend: "openai_compatible".to_string(),
6713 model: "test-embedding".to_string(),
6714 base_url: "http://127.0.0.1:1234/v1".to_string(),
6715 dimension: 3,
6716 chunking_version: default_chunking_version(),
6717 });
6718 index.write_to_disk(storage.path(), project_key);
6719
6720 let data_path = storage
6721 .path()
6722 .join("semantic")
6723 .join(project_key)
6724 .join("semantic.bin");
6725 let before = fs::read(&data_path).unwrap();
6726
6727 let matching = index.fingerprint().unwrap().as_string();
6728 assert!(SemanticIndex::read_from_disk(
6729 storage.path(),
6730 project_key,
6731 &project_root,
6732 false,
6733 Some(&matching),
6734 )
6735 .is_some());
6736
6737 let mismatched = SemanticIndexFingerprint {
6738 backend: "ollama".to_string(),
6739 model: "embeddinggemma".to_string(),
6740 base_url: "http://127.0.0.1:11434".to_string(),
6741 dimension: 3,
6742 chunking_version: default_chunking_version(),
6743 }
6744 .as_string();
6745 assert!(SemanticIndex::read_from_disk(
6746 storage.path(),
6747 project_key,
6748 &project_root,
6749 false,
6750 Some(&mismatched),
6751 )
6752 .is_none());
6753 assert_eq!(fs::read(&data_path).unwrap(), before);
6754 }
6755
6756 #[test]
6757 fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
6758 let cached = SemanticIndexFingerprint {
6759 backend: "openai_compatible".to_string(),
6760 model: "cached-model".to_string(),
6761 base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
6762 dimension: 3,
6763 chunking_version: 2,
6764 };
6765 let current = SemanticIndexFingerprint {
6766 backend: "ollama".to_string(),
6767 model: "current-model".to_string(),
6768 base_url: "https://example.org/api/embed".to_string(),
6769 dimension: 4,
6770 chunking_version: 3,
6771 };
6772
6773 let details = format_fingerprint_mismatch_details(Some(&cached), ¤t);
6774
6775 assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
6776 assert!(details.contains("model cached=cached-model current=current-model"));
6777 assert!(details.contains("base_url host cached=example.com current=example.org"));
6778 assert!(details.contains("dimension cached=3 current=4"));
6779 assert!(details.contains("chunking version cached=2 current=3"));
6780 assert!(!details.contains("secret"));
6781 assert!(!details.contains("/v1/embeddings"));
6782 assert!(!details.contains("/api/embed"));
6783 }
6784
6785 #[test]
6786 fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
6787 let storage = tempfile::tempdir().unwrap();
6788 let project_key = "proj-v3";
6789 let dir = storage.path().join("semantic").join(project_key);
6790 fs::create_dir_all(&dir).unwrap();
6791
6792 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6793 index.entries.push(EmbeddingEntry {
6794 chunk: SemanticChunk {
6795 file: PathBuf::from("/src/main.rs"),
6796 name: "handle_request".to_string(),
6797 qualified_name: None,
6798 kind: SymbolKind::Function,
6799 start_line: 0,
6800 end_line: 0,
6801 exported: true,
6802 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6803 snippet: "fn handle_request() {}".to_string(),
6804 },
6805 norm: vector_norm(&[0.1, 0.2, 0.3]),
6806 vector: vec![0.1, 0.2, 0.3],
6807 });
6808 index.dimension = 3;
6809 index
6810 .file_mtimes
6811 .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
6812 index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
6813 let fingerprint = SemanticIndexFingerprint {
6814 backend: "fastembed".to_string(),
6815 model: "test".to_string(),
6816 base_url: FALLBACK_BACKEND.to_string(),
6817 dimension: 3,
6818 chunking_version: default_chunking_version(),
6819 };
6820 index.set_fingerprint(fingerprint.clone());
6821
6822 let mut bytes = index.to_bytes();
6823 bytes[0] = SEMANTIC_INDEX_VERSION_V3;
6824 let data_path = dir.join("semantic.bin");
6825 fs::write(&data_path, &bytes).unwrap();
6826
6827 assert!(SemanticIndex::read_from_disk(
6828 storage.path(),
6829 project_key,
6830 &test_project_root(),
6831 false,
6832 Some(&fingerprint.as_string())
6833 )
6834 .is_none());
6835 assert_eq!(fs::read(&data_path).unwrap(), bytes);
6836 }
6837
6838 fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
6839 crate::symbols::Symbol {
6840 name: name.to_string(),
6841 kind,
6842 range: crate::symbols::Range {
6843 start_line: start,
6844 start_col: 0,
6845 end_line: end,
6846 end_col: 0,
6847 },
6848 signature: None,
6849 scope_chain: Vec::new(),
6850 exported: false,
6851 parent: None,
6852 }
6853 }
6854
6855 #[test]
6856 fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
6857 let project_root = PathBuf::from("/proj");
6858 let file = project_root.join("src/engine.ts");
6859 let source = "class Index {\n}\n";
6860 let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
6861 symbol.scope_chain = vec!["Engine".to_string()];
6862 symbol.signature = Some("class Index".to_string());
6863 let embed_text = build_embed_text(&symbol, source, &file, &project_root);
6864
6865 let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
6866 let chunk = chunks
6867 .iter()
6868 .find(|chunk| chunk.name == "Index")
6869 .expect("class chunk");
6870
6871 assert_eq!(chunk.name, "Index");
6872 assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
6873 assert_eq!(chunk.embed_text, embed_text);
6874 assert!(!chunk.embed_text.contains("Engine.Index"));
6875 }
6876
6877 #[test]
6882 fn symbols_to_chunks_skips_heading_symbols() {
6883 let project_root = PathBuf::from("/proj");
6884 let file = project_root.join("README.md");
6885 let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
6886
6887 let symbols = vec![
6888 make_symbol(SymbolKind::Heading, "Title", 0, 2),
6889 make_symbol(SymbolKind::Heading, "Section", 4, 6),
6890 ];
6891
6892 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
6893 assert!(
6894 chunks.is_empty(),
6895 "Heading symbols must be filtered out before embedding; got {} chunk(s)",
6896 chunks.len()
6897 );
6898 }
6899
6900 #[test]
6907 fn build_embed_text_clamps_oversized_signature() {
6908 let project_root = PathBuf::from("/proj");
6909 let file = project_root.join("cronjob.yaml");
6910 let huge_sig = "kubectl ".repeat(2000); let source = "apiVersion: batch/v1\nkind: CronJob\n";
6912
6913 let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
6914 symbol.signature = Some(huge_sig);
6915
6916 let text = build_embed_text(&symbol, source, &file, &project_root);
6917 assert!(
6918 text.chars().count() <= MAX_EMBED_TEXT_CHARS,
6919 "embed_text must be clamped to {} chars, got {}",
6920 MAX_EMBED_TEXT_CHARS,
6921 text.chars().count()
6922 );
6923 }
6924
6925 #[test]
6929 fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
6930 let project_root = PathBuf::from("/proj");
6931 let file = project_root.join("src/lib.rs");
6932 let source = "pub fn handle_request() -> bool {\n true\n}\n";
6933
6934 let symbols = vec![
6935 make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
6937 make_symbol(SymbolKind::Function, "handle_request", 0, 2),
6938 make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
6939 ];
6940
6941 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
6942 assert_eq!(
6943 chunks.len(),
6944 3,
6945 "Expected file-summary + 2 code chunks (Function + Struct), got {}",
6946 chunks.len()
6947 );
6948 let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
6949 assert!(chunks
6950 .iter()
6951 .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
6952 assert!(names.contains(&"handle_request"));
6953 assert!(names.contains(&"AuthService"));
6954 assert!(
6955 !names.contains(&"doc heading"),
6956 "Heading symbol leaked into chunks: {names:?}"
6957 );
6958 }
6959
6960 #[test]
6961 fn validate_ssrf_allows_loopback_hostnames() {
6962 for host in &[
6965 "http://localhost",
6966 "http://localhost:8080",
6967 "http://localhost:11434", "http://localhost.localdomain",
6969 "http://foo.localhost",
6970 ] {
6971 assert!(
6972 validate_base_url_no_ssrf(host).is_ok(),
6973 "Expected {host} to be allowed (loopback), got: {:?}",
6974 validate_base_url_no_ssrf(host)
6975 );
6976 }
6977 }
6978
6979 #[test]
6980 fn validate_ssrf_allows_loopback_ips() {
6981 for url in &[
6984 "http://127.0.0.1",
6985 "http://127.0.0.1:11434", "http://127.0.0.1:8080",
6987 "http://127.1.2.3",
6988 ] {
6989 let result = validate_base_url_no_ssrf(url);
6990 assert!(
6991 result.is_ok(),
6992 "Expected {url} to be allowed (loopback), got: {:?}",
6993 result
6994 );
6995 }
6996 }
6997
6998 #[test]
6999 fn validate_ssrf_rejects_private_non_loopback_ips() {
7000 for url in &[
7005 "http://192.168.1.1",
7006 "http://10.0.0.1",
7007 "http://172.16.0.1",
7008 "http://169.254.169.254",
7009 "http://100.64.0.1",
7010 ] {
7011 let result = validate_base_url_no_ssrf(url);
7012 assert!(
7013 result.is_err(),
7014 "Expected {url} to be rejected (non-loopback private), got: {:?}",
7015 result
7016 );
7017 }
7018 }
7019
7020 #[test]
7021 fn validate_ssrf_rejects_mdns_local_hostnames() {
7022 for host in &[
7025 "http://printer.local",
7026 "http://nas.local:8080",
7027 "http://homelab.local",
7028 ] {
7029 let result = validate_base_url_no_ssrf(host);
7030 assert!(
7031 result.is_err(),
7032 "Expected {host} to be rejected (mDNS), got: {:?}",
7033 result
7034 );
7035 }
7036 }
7037
7038 #[test]
7039 fn normalize_base_url_allows_localhost_for_tests() {
7040 assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
7043 assert!(normalize_base_url("http://localhost:8080").is_ok());
7044 }
7045
7046 #[test]
7047 fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
7048 use std::net::IpAddr;
7049 let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
7050
7051 assert!(blocked("10.0.0.1"));
7053 assert!(blocked("192.168.1.1"));
7054 assert!(blocked("169.254.0.1"));
7055 assert!(blocked("100.64.0.1"));
7056 assert!(
7058 blocked("198.18.0.1"),
7059 "RFC2544 benchmark range must be blocked"
7060 );
7061 assert!(blocked("224.0.0.1"), "multicast must be blocked");
7062 assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
7063 assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
7064
7065 assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
7067 assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
7068 assert!(
7069 !blocked("::ffff:127.0.0.1"),
7070 "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
7071 );
7072
7073 assert!(!blocked("8.8.8.8"));
7075 }
7076
7077 #[test]
7084 fn ort_mismatch_message_recommends_auto_fix_first() {
7085 let msg =
7086 format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
7087
7088 assert!(
7090 msg.contains("v1.9.0"),
7091 "should report detected version: {msg}"
7092 );
7093 assert!(
7094 msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
7095 "should report system path: {msg}"
7096 );
7097 assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
7098
7099 let auto_fix_pos = msg
7101 .find("Auto-fix")
7102 .expect("Auto-fix solution missing — users won't discover --fix");
7103 let remove_pos = msg
7104 .find("Remove the old library")
7105 .expect("system-rm solution missing");
7106 assert!(
7107 auto_fix_pos < remove_pos,
7108 "Auto-fix must come before manual rm — see PR comment thread"
7109 );
7110
7111 assert!(
7113 msg.contains("npx @cortexkit/aft doctor --fix"),
7114 "auto-fix command must be present and copy-pasteable: {msg}"
7115 );
7116 }
7117
7118 #[cfg(any(target_os = "linux", target_os = "macos"))]
7119 #[test]
7120 fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
7121 let requested = "libonnxruntime.so";
7122 let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
7123
7124 assert_eq!(detect_ort_version_from_path(requested), None);
7125 let (version, source) =
7126 detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
7127
7128 assert_eq!(version, Some("1.19.0".to_string()));
7129 assert_eq!(source, actual);
7130
7131 let msg = format_ort_version_mismatch(&version.unwrap(), &source);
7132 assert!(msg.contains("v1.19.0"));
7133 assert!(msg.contains(actual));
7134 }
7135
7136 #[test]
7140 fn ort_mismatch_message_handles_macos_dylib_path() {
7141 let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
7142 assert!(msg.contains("v1.9.0"));
7143 assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
7144 assert!(
7148 msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
7149 "system path should be quoted in the auto-fix sentence: {msg}"
7150 );
7151 }
7152}