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