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