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