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