1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
2use crate::config::{SemanticBackend, SemanticBackendConfig};
3use crate::fs_lock;
4use crate::parser::{detect_language, extract_symbols_from_tree, grammar_for};
5use crate::search_index::{cache_relative_path, cached_path_under_root};
6use crate::symbols::{Symbol, SymbolKind};
7use crate::{slog_info, slog_warn};
8
9use crate::local_embed::LocalEmbedder;
10use rayon::prelude::*;
11use reqwest::blocking::Client;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet, VecDeque};
14use std::env;
15use std::fmt::Display;
16use std::fs;
17use std::io::{self, BufReader, BufWriter, Cursor, Read, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20use std::time::Duration;
21use std::time::SystemTime;
22use tree_sitter::Parser;
23use url::Url;
24
25const DEFAULT_DIMENSION: usize = 384;
26const MAX_ENTRIES: usize = 1_000_000;
27const MAX_DIMENSION: usize = 4096;
30const F32_BYTES: usize = std::mem::size_of::<f32>();
31const HEADER_BYTES_V1: usize = 9;
32const HEADER_BYTES_V2: usize = 13;
33const ONNX_RUNTIME_INSTALL_HINT: &str =
34 "ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
35 apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
36 AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
37
38const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
39const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
40const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
45const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
48const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
51const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
53const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
55const DEFAULT_OPENAI_EMBEDDING_PATH: &str = "/embeddings";
56const DEFAULT_OLLAMA_EMBEDDING_PATH: &str = "/api/embed";
57const DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS: u64 = 25_000;
60const DEFAULT_QUERY_EMBEDDING_TIMEOUT_MS: u64 = 8_000;
63const DEFAULT_MAX_BATCH_SIZE: usize = 64;
64const QUERY_EMBEDDING_CACHE_CAP: usize = 1_000;
65const FALLBACK_BACKEND: &str = "none";
66const EMBEDDING_REQUEST_MAX_ATTEMPTS: usize = 3;
67const EMBEDDING_REQUEST_BACKOFF_MS: [u64; 2] = [500, 1_000];
68static SEMANTIC_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
69
70pub struct SemanticIndexLock {
71 _guard: fs_lock::LockGuard,
72}
73
74impl SemanticIndexLock {
75 pub fn acquire(storage_dir: &Path, project_key: &str) -> std::io::Result<Self> {
76 let dir = storage_dir.join("semantic").join(project_key);
77 fs::create_dir_all(&dir)?;
78 let path = dir.join("cache.lock");
79 let _acquire_guard = SEMANTIC_LOCK_ACQUIRE_MUTEX
80 .lock()
81 .map_err(|_| std::io::Error::other("semantic cache lock acquisition mutex poisoned"))?;
82 fs_lock::try_acquire(&path, Duration::from_secs(2))
83 .map(|guard| Self { _guard: guard })
84 .map_err(|error| match error {
85 fs_lock::AcquireError::Timeout => {
86 std::io::Error::other("timed out acquiring semantic cache lock")
87 }
88 fs_lock::AcquireError::Io(error) => error,
89 })
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct SemanticIndexFingerprint {
95 pub backend: String,
96 pub model: String,
97 #[serde(default)]
98 pub base_url: String,
99 pub dimension: usize,
100 #[serde(default = "default_chunking_version")]
101 pub chunking_version: u32,
102}
103
104fn default_chunking_version() -> u32 {
105 2
106}
107
108impl SemanticIndexFingerprint {
109 fn from_config(config: &SemanticBackendConfig, dimension: usize) -> Self {
110 let base_url = config
113 .base_url
114 .as_ref()
115 .and_then(|u| normalize_base_url(u).ok())
116 .unwrap_or_else(|| FALLBACK_BACKEND.to_string());
117 Self {
118 backend: config.backend.as_str().to_string(),
119 model: config.model.clone(),
120 base_url,
121 dimension,
122 chunking_version: default_chunking_version(),
123 }
124 }
125
126 pub fn as_string(&self) -> String {
127 serde_json::to_string(self).unwrap_or_else(|_| String::new())
128 }
129
130 pub(crate) fn for_config_dimension(config: &SemanticBackendConfig, dimension: usize) -> Self {
131 Self::from_config(config, dimension)
132 }
133
134 fn matches_expected(&self, expected: &str) -> bool {
135 let encoded = self.as_string();
136 !encoded.is_empty() && encoded == expected
137 }
138}
139
140enum SemanticEmbeddingEngine {
141 Local(LocalEmbedder),
144 OpenAiCompatible {
145 client: Client,
146 model: String,
147 base_url: String,
148 api_key: Option<String>,
149 },
150 Ollama {
151 client: Client,
152 model: String,
153 base_url: String,
154 },
155}
156
157pub struct SemanticEmbeddingModel {
158 backend: SemanticBackend,
159 model: String,
160 base_url: Option<String>,
161 timeout_ms: u64,
162 max_batch_size: usize,
163 dimension: Option<usize>,
164 engine: SemanticEmbeddingEngine,
165 query_embedding_cache: HashMap<String, Vec<f32>>,
166 query_embedding_cache_order: VecDeque<String>,
167 query_embedding_cache_hits: u64,
168 query_embedding_cache_misses: u64,
169}
170
171pub type EmbeddingModel = SemanticEmbeddingModel;
172
173fn validate_embedding_batch(
174 vectors: &[Vec<f32>],
175 expected_count: usize,
176 context: &str,
177) -> Result<(), String> {
178 if expected_count > 0 && vectors.is_empty() {
179 return Err(format!(
180 "{context} returned no vectors for {expected_count} inputs"
181 ));
182 }
183
184 if vectors.len() != expected_count {
185 return Err(format!(
186 "{context} returned {} vectors for {} inputs",
187 vectors.len(),
188 expected_count
189 ));
190 }
191
192 let Some(first_vector) = vectors.first() else {
193 return Ok(());
194 };
195 let expected_dimension = first_vector.len();
196 validate_embedding_dimension(expected_dimension)
197 .map_err(|error| format!("{context} returned {error}"))?;
198 for (index, vector) in vectors.iter().enumerate() {
199 if vector.len() != expected_dimension {
200 return Err(format!(
201 "{context} returned inconsistent embedding dimensions: vector 0 has length {expected_dimension}, vector {index} has length {}",
202 vector.len()
203 ));
204 }
205 }
206
207 Ok(())
208}
209
210fn validate_embedding_dimension(dimension: usize) -> Result<(), String> {
211 if dimension == 0 || dimension > MAX_DIMENSION {
212 return Err(format!(
213 "invalid embedding dimension: {dimension}; supported range is 1..={MAX_DIMENSION}"
214 ));
215 }
216
217 Ok(())
218}
219
220fn normalize_base_url(raw: &str) -> Result<String, String> {
224 let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
225 let scheme = parsed.scheme();
226 if scheme != "http" && scheme != "https" {
227 return Err(format!(
228 "unsupported URL scheme '{}' — only http:// and https:// are allowed",
229 scheme
230 ));
231 }
232 Ok(parsed.to_string().trim_end_matches('/').to_string())
233}
234
235pub fn validate_base_url_no_ssrf(raw: &str) -> Result<(), String> {
250 use std::net::{IpAddr, ToSocketAddrs};
251
252 let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
253
254 let host = parsed.host_str().unwrap_or("");
255
256 let is_loopback_host =
261 host == "localhost" || host == "localhost.localdomain" || host.ends_with(".localhost");
262 if is_loopback_host {
263 return Ok(());
264 }
265
266 if host.ends_with(".local") {
269 return Err(format!(
270 "base_url host '{host}' is an mDNS name — only loopback (localhost / 127.0.0.1) and public endpoints are allowed"
271 ));
272 }
273
274 let port = parsed.port_or_known_default().unwrap_or(443);
277 let addr_str = format!("{host}:{port}");
278 let addrs: Vec<IpAddr> = addr_str
279 .to_socket_addrs()
280 .map(|iter| iter.map(|sa| sa.ip()).collect())
281 .unwrap_or_default();
282 for ip in &addrs {
283 if is_private_non_loopback_ip(ip) {
284 return Err(format!(
285 "base_url '{raw}' resolves to a private/reserved IP — only loopback (127.0.0.1) and public endpoints are allowed"
286 ));
287 }
288 }
289
290 Ok(())
291}
292
293fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
304 if ip.to_canonical().is_loopback() {
307 return false;
308 }
309 crate::url_fetch::is_private_or_reserved_ip(*ip)
310}
311
312fn build_openai_embeddings_endpoint(base_url: &str) -> String {
313 if base_url.ends_with("/v1") {
314 format!("{base_url}{DEFAULT_OPENAI_EMBEDDING_PATH}")
315 } else {
316 format!("{base_url}/v1{}", DEFAULT_OPENAI_EMBEDDING_PATH)
317 }
318}
319
320fn build_ollama_embeddings_endpoint(base_url: &str) -> String {
321 if base_url.ends_with("/api") {
322 format!("{base_url}/embed")
323 } else {
324 format!("{base_url}{DEFAULT_OLLAMA_EMBEDDING_PATH}")
325 }
326}
327
328fn normalize_api_key(value: Option<String>) -> Option<String> {
329 value.and_then(|token| {
330 let token = token.trim();
331 if token.is_empty() {
332 None
333 } else {
334 Some(token.to_string())
335 }
336 })
337}
338
339fn is_retryable_embedding_status(status: reqwest::StatusCode) -> bool {
340 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
341}
342
343fn embedding_response_body_is_transient(status: reqwest::StatusCode, raw: &str) -> bool {
349 if !matches!(
350 status,
351 reqwest::StatusCode::BAD_REQUEST
352 | reqwest::StatusCode::CONFLICT
353 | reqwest::StatusCode::REQUEST_TIMEOUT
354 | reqwest::StatusCode::LOCKED
355 | reqwest::StatusCode::TOO_EARLY
356 ) {
357 return false;
358 }
359
360 let lower = raw.to_ascii_lowercase();
361 let normalized = lower.trim();
362
363 normalized.contains("model was unloaded while the request was still in queue")
364 || normalized == "model is loading"
365 || normalized.starts_with("model is loading,")
366 || normalized.contains(r#""error":"model is loading"#)
367 || normalized.contains(r#""message":"model is loading"#)
368 || normalized == "model not loaded"
369 || normalized.contains(r#""error":"model not loaded""#)
370 || normalized.contains(r#""message":"model not loaded""#)
371 || normalized == "loading model into memory"
372 || normalized.contains(r#""error":"loading model into memory""#)
373 || normalized.contains(r#""message":"loading model into memory""#)
374 || normalized == "model is being loaded"
375 || normalized.contains(r#""error":"model is being loaded""#)
376 || normalized.contains(r#""message":"model is being loaded""#)
377 || normalized == "model is currently loading"
378 || normalized.contains(r#""error":"model is currently loading""#)
379 || normalized.contains(r#""message":"model is currently loading""#)
380}
381
382fn is_retryable_embedding_error(error: &reqwest::Error) -> bool {
383 error.is_connect()
384}
385
386fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
392 error.is_connect() || error.is_timeout()
393}
394
395fn embedding_response_read_error_is_transient(error: &reqwest::Error) -> bool {
396 embedding_send_error_is_transient(error) || error.is_body() || error.is_decode()
397}
398
399pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
406
407pub fn embedding_failure_is_transient(error: &str) -> bool {
410 error.contains(TRANSIENT_EMBEDDING_MARKER)
411}
412
413pub fn strip_transient_embedding_marker(error: &str) -> String {
415 error.replace(TRANSIENT_EMBEDDING_MARKER, "")
416}
417
418fn sleep_before_embedding_retry(attempt_index: usize) {
419 if let Some(delay_ms) = EMBEDDING_REQUEST_BACKOFF_MS.get(attempt_index) {
420 std::thread::sleep(Duration::from_millis(*delay_ms));
421 }
422}
423
424fn send_embedding_request<F>(mut make_request: F, backend_label: &str) -> Result<String, String>
425where
426 F: FnMut() -> reqwest::blocking::RequestBuilder,
427{
428 for attempt_index in 0..EMBEDDING_REQUEST_MAX_ATTEMPTS {
429 let last_attempt = attempt_index + 1 == EMBEDDING_REQUEST_MAX_ATTEMPTS;
430
431 let response = match make_request().send() {
432 Ok(response) => response,
433 Err(error) => {
434 if !last_attempt && is_retryable_embedding_error(&error) {
435 sleep_before_embedding_retry(attempt_index);
436 continue;
437 }
438 let marker = if embedding_send_error_is_transient(&error) {
442 TRANSIENT_EMBEDDING_MARKER
443 } else {
444 ""
445 };
446 return Err(format!("{marker}{backend_label} request failed: {error}"));
447 }
448 };
449
450 let status = response.status();
451 let raw = match response.text() {
452 Ok(raw) => raw,
453 Err(error) => {
454 if !last_attempt && embedding_response_read_error_is_transient(&error) {
455 sleep_before_embedding_retry(attempt_index);
456 continue;
457 }
458 let marker = if embedding_response_read_error_is_transient(&error) {
459 TRANSIENT_EMBEDDING_MARKER
460 } else {
461 ""
462 };
463 return Err(format!(
464 "{marker}{backend_label} response read failed: {error}"
465 ));
466 }
467 };
468
469 if status.is_success() {
470 return Ok(raw);
471 }
472
473 let body_transient = embedding_response_body_is_transient(status, &raw);
477 if !last_attempt && (is_retryable_embedding_status(status) || body_transient) {
478 sleep_before_embedding_retry(attempt_index);
479 continue;
480 }
481
482 let marker = if is_retryable_embedding_status(status) || body_transient {
488 TRANSIENT_EMBEDDING_MARKER
489 } else {
490 ""
491 };
492 return Err(format!(
493 "{marker}{backend_label} request failed (HTTP {}): {}",
494 status, raw
495 ));
496 }
497
498 unreachable!("embedding request retries exhausted without returning")
499}
500
501fn configured_embedding_timeout_ms(config: &SemanticBackendConfig) -> u64 {
502 if config.timeout_ms == 0 {
503 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS
504 } else {
505 config.timeout_ms
506 }
507}
508
509impl SemanticEmbeddingModel {
510 pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, String> {
511 Self::from_config_with_timeout_ms(config, configured_embedding_timeout_ms(config))
512 }
513
514 pub fn from_config_for_query(config: &SemanticBackendConfig) -> Result<Self, String> {
515 let timeout_ms =
516 configured_embedding_timeout_ms(config).min(DEFAULT_QUERY_EMBEDDING_TIMEOUT_MS);
517 Self::from_config_with_timeout_ms(config, timeout_ms)
518 }
519
520 fn from_config_with_timeout_ms(
521 config: &SemanticBackendConfig,
522 timeout_ms: u64,
523 ) -> Result<Self, String> {
524 let max_batch_size = if config.max_batch_size == 0 {
525 DEFAULT_MAX_BATCH_SIZE
526 } else {
527 config.max_batch_size
528 };
529
530 let api_key_env = normalize_api_key(config.api_key_env.clone());
531 let model = config.model.clone();
532
533 let client = Client::builder()
534 .timeout(Duration::from_millis(timeout_ms))
535 .redirect(reqwest::redirect::Policy::none())
536 .build()
537 .map_err(|error| format!("failed to configure embedding client: {error}"))?;
538
539 let engine = match config.backend {
540 SemanticBackend::Fastembed => {
541 SemanticEmbeddingEngine::Local(LocalEmbedder::new(&model)?)
542 }
543 SemanticBackend::OpenAiCompatible => {
544 let raw = config.base_url.as_ref().ok_or_else(|| {
545 "base_url is required for openai_compatible backend".to_string()
546 })?;
547 let base_url = normalize_base_url(raw)?;
548
549 let api_key = match api_key_env {
550 Some(var_name) => Some(env::var(&var_name).map_err(|_| {
551 format!("missing api_key_env '{var_name}' for openai_compatible backend")
552 })?),
553 None => None,
554 };
555
556 SemanticEmbeddingEngine::OpenAiCompatible {
557 client,
558 model,
559 base_url,
560 api_key,
561 }
562 }
563 SemanticBackend::Ollama => {
564 let raw = config
565 .base_url
566 .as_ref()
567 .ok_or_else(|| "base_url is required for ollama backend".to_string())?;
568 let base_url = normalize_base_url(raw)?;
569
570 SemanticEmbeddingEngine::Ollama {
571 client,
572 model,
573 base_url,
574 }
575 }
576 };
577
578 Ok(Self {
579 backend: config.backend,
580 model: config.model.clone(),
581 base_url: config.base_url.clone(),
582 timeout_ms,
583 max_batch_size,
584 dimension: None,
585 engine,
586 query_embedding_cache: HashMap::new(),
587 query_embedding_cache_order: VecDeque::new(),
588 query_embedding_cache_hits: 0,
589 query_embedding_cache_misses: 0,
590 })
591 }
592
593 pub fn backend(&self) -> SemanticBackend {
594 self.backend
595 }
596
597 pub fn model(&self) -> &str {
598 &self.model
599 }
600
601 pub fn base_url(&self) -> Option<&str> {
602 self.base_url.as_deref()
603 }
604
605 pub fn max_batch_size(&self) -> usize {
606 self.max_batch_size
607 }
608
609 pub fn timeout_ms(&self) -> u64 {
610 self.timeout_ms
611 }
612
613 pub fn fingerprint(
614 &mut self,
615 config: &SemanticBackendConfig,
616 ) -> Result<SemanticIndexFingerprint, String> {
617 let dimension = self.dimension()?;
618 Ok(SemanticIndexFingerprint::from_config(config, dimension))
619 }
620
621 pub fn dimension(&mut self) -> Result<usize, String> {
622 if let Some(dimension) = self.dimension {
623 return Ok(dimension);
624 }
625
626 let dimension = match &mut self.engine {
627 SemanticEmbeddingEngine::Local(model) => {
628 let vectors = model.embed(&["semantic index fingerprint probe".to_string()])?;
629 vectors
630 .first()
631 .map(|v| v.len())
632 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
633 }
634 SemanticEmbeddingEngine::OpenAiCompatible { .. } => {
635 let vectors =
636 self.embed_texts(vec!["semantic index fingerprint probe".to_string()])?;
637 vectors
638 .first()
639 .map(|v| v.len())
640 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
641 }
642 SemanticEmbeddingEngine::Ollama { .. } => {
643 let vectors =
644 self.embed_texts(vec!["semantic index fingerprint probe".to_string()])?;
645 vectors
646 .first()
647 .map(|v| v.len())
648 .ok_or_else(|| "embedding backend returned no vectors".to_string())?
649 }
650 };
651
652 self.dimension = Some(dimension);
653 Ok(dimension)
654 }
655
656 pub fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
657 self.embed_texts(texts)
658 }
659
660 pub fn embed_query_cached(&mut self, query: &str) -> Result<Vec<f32>, String> {
661 if let Some(vector) = self.query_embedding_cache.get(query) {
662 self.query_embedding_cache_hits += 1;
663 return Ok(vector.clone());
664 }
665
666 self.query_embedding_cache_misses += 1;
667 let embeddings = self.embed_texts(vec![query.to_string()])?;
668 let vector = embeddings
669 .first()
670 .cloned()
671 .ok_or_else(|| "embedding model returned no query vector".to_string())?;
672
673 if self.query_embedding_cache.len() >= QUERY_EMBEDDING_CACHE_CAP {
674 if let Some(oldest) = self.query_embedding_cache_order.pop_front() {
675 self.query_embedding_cache.remove(&oldest);
676 }
677 }
678 self.query_embedding_cache
679 .insert(query.to_string(), vector.clone());
680 self.query_embedding_cache_order
681 .push_back(query.to_string());
682
683 Ok(vector)
684 }
685
686 pub fn query_embedding_cache_stats(&self) -> (u64, u64, usize) {
687 (
688 self.query_embedding_cache_hits,
689 self.query_embedding_cache_misses,
690 self.query_embedding_cache.len(),
691 )
692 }
693
694 fn embed_texts(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
695 match &mut self.engine {
696 SemanticEmbeddingEngine::Local(model) => model
697 .embed(&texts)
698 .map_err(|error| format!("failed to embed batch: {error}")),
699 SemanticEmbeddingEngine::OpenAiCompatible {
700 client,
701 model,
702 base_url,
703 api_key,
704 } => {
705 let expected_text_count = texts.len();
706 let endpoint = build_openai_embeddings_endpoint(base_url);
707 let body = serde_json::json!({
708 "input": texts,
709 "model": model,
710 });
711
712 let raw = send_embedding_request(
713 || {
714 let mut request = client.post(&endpoint).json(&body);
724
725 if let Some(api_key) = api_key {
726 request = request.header("Authorization", format!("Bearer {api_key}"));
727 }
728
729 request
730 },
731 "openai compatible",
732 )?;
733
734 #[derive(Deserialize)]
735 struct OpenAiResponse {
736 data: Vec<OpenAiEmbeddingResult>,
737 }
738
739 #[derive(Deserialize)]
740 struct OpenAiEmbeddingResult {
741 embedding: Vec<f32>,
742 index: Option<u32>,
743 }
744
745 let parsed: OpenAiResponse = serde_json::from_str(&raw)
746 .map_err(|error| format!("invalid openai compatible response: {error}"))?;
747 if parsed.data.len() != expected_text_count {
748 return Err(format!(
749 "openai compatible response returned {} embeddings for {} inputs",
750 parsed.data.len(),
751 expected_text_count
752 ));
753 }
754
755 let mut vectors = vec![Vec::new(); parsed.data.len()];
756 for (i, item) in parsed.data.into_iter().enumerate() {
757 let index = item.index.unwrap_or(i as u32) as usize;
758 if index >= vectors.len() {
759 return Err(
760 "openai compatible response contains invalid vector index".to_string()
761 );
762 }
763 vectors[index] = item.embedding;
764 }
765
766 for vector in &vectors {
767 if vector.is_empty() {
768 return Err(
769 "openai compatible response contained missing vectors".to_string()
770 );
771 }
772 }
773
774 self.dimension = vectors.first().map(Vec::len);
775 Ok(vectors)
776 }
777 SemanticEmbeddingEngine::Ollama {
778 client,
779 model,
780 base_url,
781 } => {
782 let expected_text_count = texts.len();
783 let endpoint = build_ollama_embeddings_endpoint(base_url);
784
785 #[derive(Serialize)]
786 struct OllamaPayload<'a> {
787 model: &'a str,
788 input: Vec<String>,
789 }
790
791 let payload = OllamaPayload {
792 model,
793 input: texts,
794 };
795
796 let raw = send_embedding_request(
797 || {
798 client.post(&endpoint).json(&payload)
803 },
804 "ollama",
805 )?;
806
807 #[derive(Deserialize)]
808 struct OllamaResponse {
809 embeddings: Vec<Vec<f32>>,
810 }
811
812 let parsed: OllamaResponse = serde_json::from_str(&raw)
813 .map_err(|error| format!("invalid ollama response: {error}"))?;
814 if parsed.embeddings.is_empty() {
815 return Err("ollama response returned no embeddings".to_string());
816 }
817 if parsed.embeddings.len() != expected_text_count {
818 return Err(format!(
819 "ollama response returned {} embeddings for {} inputs",
820 parsed.embeddings.len(),
821 expected_text_count
822 ));
823 }
824
825 let vectors = parsed.embeddings;
826 for vector in &vectors {
827 if vector.is_empty() {
828 return Err("ollama response contained empty embeddings".to_string());
829 }
830 }
831
832 self.dimension = vectors.first().map(Vec::len);
833 Ok(vectors)
834 }
835 }
836 }
837}
838
839pub fn pre_validate_onnx_runtime() -> Result<(), String> {
843 let dylib_path = std::env::var("ORT_DYLIB_PATH").ok();
844
845 #[cfg(any(target_os = "linux", target_os = "macos"))]
846 {
847 #[cfg(target_os = "linux")]
848 let default_name = "libonnxruntime.so";
849 #[cfg(target_os = "macos")]
850 let default_name = "libonnxruntime.dylib";
851
852 let lib_name = dylib_path.as_deref().unwrap_or(default_name);
853
854 unsafe {
855 let c_name = std::ffi::CString::new(lib_name)
856 .map_err(|e| format!("invalid library path: {}", e))?;
857 let handle = libc::dlopen(c_name.as_ptr(), libc::RTLD_NOW);
858 if handle.is_null() {
859 let err = libc::dlerror();
860 let msg = if err.is_null() {
861 "unknown dlopen error".to_string()
862 } else {
863 std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
864 };
865 return Err(format!(
866 "ONNX Runtime not found. dlopen('{}') failed: {}. \
867 Run `npx @cortexkit/aft doctor` to diagnose.",
868 lib_name, msg
869 ));
870 }
871
872 let (detected_version, version_source) =
877 detect_ort_version_from_loaded_library(handle, lib_name);
878
879 libc::dlclose(handle);
880
881 if let Some(ref version) = detected_version {
883 let parts: Vec<&str> = version.split('.').collect();
884 if let (Some(major), Some(minor)) = (
885 parts.first().and_then(|s| s.parse::<u32>().ok()),
886 parts.get(1).and_then(|s| s.parse::<u32>().ok()),
887 ) {
888 if major != 1 || minor < 20 {
889 return Err(format_ort_version_mismatch(version, &version_source));
890 }
891 }
892 }
893 }
894 }
895
896 #[cfg(target_os = "windows")]
897 {
898 let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
903
904 #[link(name = "kernel32")]
908 extern "system" {
909 fn LoadLibraryExW(
910 lpLibFileName: *const u16,
911 hFile: *mut std::ffi::c_void,
912 dwFlags: u32,
913 ) -> *mut std::ffi::c_void;
914 fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
915 fn GetModuleFileNameW(
916 hModule: *mut std::ffi::c_void,
917 lpFilename: *mut u16,
918 nSize: u32,
919 ) -> u32;
920 }
921
922 #[link(name = "version")]
923 extern "system" {
924 fn GetFileVersionInfoSizeW(lptstrFilename: *const u16, lpdwHandle: *mut u32) -> u32;
925 fn GetFileVersionInfoW(
926 lptstrFilename: *const u16,
927 dwHandle: u32,
928 dwLen: u32,
929 lpData: *mut std::ffi::c_void,
930 ) -> i32;
931 fn VerQueryValueW(
932 pBlock: *mut std::ffi::c_void,
933 lpSubBlock: *const u16,
934 lplpBuffer: *mut *mut std::ffi::c_void,
935 puLen: *mut u32,
936 ) -> i32;
937 }
938
939 #[repr(C)]
940 struct VS_FIXEDFILEINFO {
941 dw_signature: u32,
942 dw_struc_version: u32,
943 dw_file_version_ms: u32, dw_file_version_ls: u32, dw_product_version_ms: u32,
946 dw_product_version_ls: u32,
947 dw_file_flags_mask: u32,
948 dw_file_flags: u32,
949 dw_file_os: u32,
950 dw_file_type: u32,
951 dw_file_subtype: u32,
952 dw_file_date_ms: u32,
953 dw_file_date_ls: u32,
954 }
955
956 unsafe {
957 use std::os::windows::ffi::OsStrExt;
958 let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
959 .encode_wide()
960 .chain(std::iter::once(0))
961 .collect();
962
963 let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
964 if handle.is_null() {
965 let err = std::io::Error::last_os_error();
966 return Err(format!(
967 "ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
968 Run `npx @cortexkit/aft doctor` to diagnose.",
969 lib_name, err
970 ));
971 }
972
973 let mut detected_major: u32 = 0;
976 let mut detected_minor: u32 = 0;
977 let mut path_buf = [0u16; 32767];
983 let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 32767);
984 if path_len > 0 {
985 let mut dummy_handle: u32 = 0;
986 let info_size = GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
987 if info_size > 0 {
988 let mut info = vec![0u8; info_size as usize];
989 if GetFileVersionInfoW(
990 path_buf.as_ptr(),
991 0,
992 info_size,
993 info.as_mut_ptr() as *mut std::ffi::c_void,
994 ) != 0
995 {
996 let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
997 let mut vs_info: *mut std::ffi::c_void = std::ptr::null_mut();
998 let mut vs_len: u32 = 0;
999 if VerQueryValueW(
1000 info.as_mut_ptr() as *mut std::ffi::c_void,
1001 sub_block.as_ptr(),
1002 &mut vs_info,
1003 &mut vs_len,
1004 ) != 0
1005 && !vs_info.is_null()
1006 {
1007 let fixed = vs_info as *const VS_FIXEDFILEINFO;
1008 detected_major = (*fixed).dw_file_version_ms >> 16;
1009 detected_minor = (*fixed).dw_file_version_ms & 0xFFFF;
1010 }
1011 }
1012 }
1013 }
1014
1015 FreeLibrary(handle);
1016
1017 if detected_major != 0 && (detected_major != 1 || detected_minor < 20) {
1021 let ver = format!("{}.{}", detected_major, detected_minor);
1022 return Err(format_ort_version_mismatch(&ver, lib_name));
1023 }
1024 }
1025 }
1026
1027 Ok(())
1028}
1029
1030#[cfg(any(target_os = "linux", target_os = "macos"))]
1031unsafe fn loaded_library_path_from_handle(handle: *mut std::ffi::c_void) -> Option<String> {
1032 let symbol_name = std::ffi::CString::new("OrtGetApiBase").ok()?;
1033 let symbol = unsafe { libc::dlsym(handle, symbol_name.as_ptr()) };
1034 if symbol.is_null() {
1035 return None;
1036 }
1037
1038 let mut info = std::mem::MaybeUninit::<libc::Dl_info>::uninit();
1039 if unsafe { libc::dladdr(symbol, info.as_mut_ptr()) } == 0 {
1040 return None;
1041 }
1042
1043 let info = unsafe { info.assume_init() };
1044 if info.dli_fname.is_null() {
1045 return None;
1046 }
1047
1048 Some(
1049 unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }
1050 .to_string_lossy()
1051 .into_owned(),
1052 )
1053}
1054
1055#[cfg(any(target_os = "linux", target_os = "macos"))]
1056fn detect_ort_version_from_resolved_or_requested(
1057 resolved_path: Option<String>,
1058 requested_lib_name: &str,
1059) -> (Option<String>, String) {
1060 if let Some(path) = resolved_path {
1061 if let Some(version) = detect_ort_version_from_path(&path) {
1062 return (Some(version), path);
1063 }
1064 return (detect_ort_version_from_path(requested_lib_name), path);
1065 }
1066
1067 (
1068 detect_ort_version_from_path(requested_lib_name),
1069 requested_lib_name.to_string(),
1070 )
1071}
1072
1073#[cfg(any(target_os = "linux", target_os = "macos"))]
1074fn detect_ort_version_from_loaded_library(
1075 handle: *mut std::ffi::c_void,
1076 requested_lib_name: &str,
1077) -> (Option<String>, String) {
1078 detect_ort_version_from_resolved_or_requested(
1079 unsafe { loaded_library_path_from_handle(handle) },
1080 requested_lib_name,
1081 )
1082}
1083
1084#[cfg(any(target_os = "linux", target_os = "macos"))]
1087fn detect_ort_version_from_path(lib_path: &str) -> Option<String> {
1088 let path = std::path::Path::new(lib_path);
1089
1090 for candidate in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
1092 .into_iter()
1093 .flatten()
1094 {
1095 if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
1096 if let Some(version) = extract_version_from_filename(name) {
1097 return Some(version);
1098 }
1099 }
1100 }
1101
1102 if let Some(parent) = path.parent() {
1104 if let Ok(entries) = std::fs::read_dir(parent) {
1105 for entry in entries.flatten() {
1106 if let Some(name) = entry.file_name().to_str() {
1107 if name.starts_with("libonnxruntime") {
1108 if let Some(version) = extract_version_from_filename(name) {
1109 return Some(version);
1110 }
1111 }
1112 }
1113 }
1114 }
1115 }
1116
1117 None
1118}
1119
1120#[cfg(any(target_os = "linux", target_os = "macos"))]
1122fn extract_version_from_filename(name: &str) -> Option<String> {
1123 let re = regex::Regex::new(r"(\d+\.\d+\.\d+)").ok()?;
1125 re.find(name).map(|m| m.as_str().to_string())
1126}
1127
1128fn suggest_removal_command(lib_path: &str) -> String {
1129 if lib_path.starts_with("/usr/local/lib")
1130 || lib_path == "libonnxruntime.so"
1131 || lib_path == "libonnxruntime.dylib"
1132 {
1133 #[cfg(target_os = "linux")]
1134 return " sudo rm /usr/local/lib/libonnxruntime* && sudo ldconfig".to_string();
1135 #[cfg(target_os = "macos")]
1136 return " sudo rm /usr/local/lib/libonnxruntime*".to_string();
1137 }
1138 format!(" rm '{}'", lib_path)
1139}
1140
1141pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
1147 format!(
1148 "ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \
1149 Solutions:\n\
1150 1. Auto-fix (recommended): run `npx @cortexkit/aft doctor --fix`. \
1151 This downloads AFT-managed ONNX Runtime v1.24 into AFT's storage and \
1152 configures the bridge to load it instead of the system library — no \
1153 changes to '{}'.\n\
1154 2. Remove the old library and restart (AFT auto-downloads the correct version on next start):\n\
1155 {}\n\
1156 3. Or install ONNX Runtime 1.24 system-wide: https://github.com/microsoft/onnxruntime/releases/tag/v1.24.0\n\
1157 4. Run `npx @cortexkit/aft doctor` for full diagnostics.",
1158 version,
1159 lib_name,
1160 lib_name,
1161 suggest_removal_command(lib_name),
1162 )
1163}
1164
1165pub fn is_onnx_runtime_unavailable(message: &str) -> bool {
1166 if message.trim_start().starts_with("ONNX Runtime not found.") {
1167 return true;
1168 }
1169
1170 let message = message.to_ascii_lowercase();
1171 let mentions_onnx_runtime = ["onnx runtime", "onnxruntime", "libonnxruntime"]
1172 .iter()
1173 .any(|pattern| message.contains(pattern));
1174 let mentions_dynamic_load_failure = [
1175 "shared library",
1176 "dynamic library",
1177 "failed to load",
1178 "could not load",
1179 "unable to load",
1180 "dlopen",
1181 "loadlibrary",
1182 "no such file",
1183 "not found",
1184 ]
1185 .iter()
1186 .any(|pattern| message.contains(pattern));
1187
1188 mentions_onnx_runtime && mentions_dynamic_load_failure
1189}
1190
1191pub fn format_embedding_init_error(error: impl Display) -> String {
1192 let message = error.to_string();
1193
1194 if is_onnx_runtime_unavailable(&message) {
1195 return format!("{ONNX_RUNTIME_INSTALL_HINT} Original error: {message}");
1196 }
1197
1198 format!("failed to initialize semantic embedding model: {message}")
1199}
1200
1201#[derive(Debug, Clone)]
1203pub struct SemanticChunk {
1204 pub file: PathBuf,
1206 pub name: String,
1208 pub qualified_name: Option<String>,
1210 pub kind: SymbolKind,
1212 pub start_line: u32,
1214 pub end_line: u32,
1215 pub exported: bool,
1217 pub embed_text: String,
1219 pub snippet: String,
1221}
1222
1223#[derive(Debug, Clone)]
1225pub struct EmbeddingEntry {
1226 chunk: SemanticChunk,
1227 vector: Vec<f32>,
1228}
1229
1230#[derive(Debug, Clone)]
1232pub struct SemanticIndex {
1233 entries: Vec<EmbeddingEntry>,
1234 file_mtimes: HashMap<PathBuf, SystemTime>,
1236 file_sizes: HashMap<PathBuf, u64>,
1238 file_hashes: HashMap<PathBuf, blake3::Hash>,
1239 dimension: usize,
1241 fingerprint: Option<SemanticIndexFingerprint>,
1242 project_root: PathBuf,
1243 deferred_files: HashSet<PathBuf>,
1244}
1245
1246#[derive(Debug, Clone, Copy)]
1247struct IndexedFileMetadata {
1248 mtime: SystemTime,
1249 size: u64,
1250 content_hash: blake3::Hash,
1251}
1252
1253#[derive(Debug, Default, Clone, Copy)]
1256pub struct RefreshSummary {
1257 pub changed: usize,
1258 pub added: usize,
1259 pub deleted: usize,
1260 pub total_processed: usize,
1261}
1262
1263impl RefreshSummary {
1264 pub fn is_noop(&self) -> bool {
1266 self.changed == 0 && self.added == 0 && self.deleted == 0
1267 }
1268}
1269
1270#[derive(Debug, Default)]
1271pub struct InvalidatedFilesRefresh {
1272 pub added_entries: Vec<EmbeddingEntry>,
1276 pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
1277 pub completed_paths: Vec<PathBuf>,
1278 pub summary: RefreshSummary,
1279}
1280
1281#[derive(Debug, Clone)]
1282struct ReusableEmbedding {
1283 embed_text: String,
1284 vector: Vec<f32>,
1285}
1286
1287type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
1288
1289#[derive(Debug, Clone)]
1291pub struct SemanticResult {
1292 pub file: PathBuf,
1293 pub name: String,
1294 pub qualified_name: Option<String>,
1295 pub kind: SymbolKind,
1296 pub start_line: u32,
1297 pub end_line: u32,
1298 pub exported: bool,
1299 pub snippet: String,
1300 pub score: f32,
1301 pub rank_score: f32,
1302 pub cap_protected: bool,
1303 pub source: &'static str,
1304}
1305
1306impl SemanticIndex {
1307 pub fn new(project_root: PathBuf, dimension: usize) -> Self {
1308 debug_assert!(project_root.is_absolute());
1309 Self {
1310 entries: Vec::new(),
1311 file_mtimes: HashMap::new(),
1312 file_sizes: HashMap::new(),
1313 file_hashes: HashMap::new(),
1314 dimension,
1315 fingerprint: None,
1316 project_root,
1317 deferred_files: HashSet::new(),
1318 }
1319 }
1320
1321 pub fn entry_count(&self) -> usize {
1323 self.entries.len()
1324 }
1325
1326 pub fn indexed_file_count(&self) -> usize {
1328 self.file_mtimes.len()
1329 }
1330
1331 pub fn status_label(&self) -> &'static str {
1333 if self.entries.is_empty() {
1334 "empty"
1335 } else {
1336 "ready"
1337 }
1338 }
1339
1340 fn collect_chunks(
1341 project_root: &Path,
1342 files: &[PathBuf],
1343 ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
1344 let collect_started = std::time::Instant::now();
1345 let per_file: Vec<(
1346 PathBuf,
1347 Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
1348 )> = files
1349 .par_iter()
1350 .map_init(HashMap::new, |parsers, file| {
1351 let result = collect_semantic_file(project_root, file, parsers);
1352 (file.clone(), result)
1353 })
1354 .collect();
1355
1356 let mut chunks: Vec<SemanticChunk> = Vec::new();
1357 let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
1358
1359 for (file, result) in per_file {
1360 match result {
1361 Ok((metadata, file_chunks)) => {
1362 file_metadata.insert(file, metadata);
1363 chunks.extend(file_chunks);
1364 }
1365 Err(error) => {
1366 if error == "unsupported file extension" {
1372 continue;
1373 }
1374 slog_warn!(
1375 "failed to collect semantic chunks for {}: {}",
1376 file.display(),
1377 error
1378 );
1379 }
1380 }
1381 }
1382
1383 slog_info!(
1384 "semantic collect: {} chunks from {} files in {} ms",
1385 chunks.len(),
1386 file_metadata.len(),
1387 collect_started.elapsed().as_millis()
1388 );
1389
1390 (chunks, file_metadata)
1391 }
1392
1393 fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
1394 let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
1395 let mut reuse_map: ChunkReuseMap = HashMap::new();
1396
1397 for entry in &self.entries {
1398 if !requested.contains(entry.chunk.file.as_path()) {
1399 continue;
1400 }
1401
1402 let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
1407 reuse_map
1408 .entry(entry.chunk.file.clone())
1409 .or_default()
1410 .entry(hash)
1411 .or_default()
1412 .push(ReusableEmbedding {
1413 embed_text: entry.chunk.embed_text.clone(),
1414 vector: entry.vector.clone(),
1415 });
1416 }
1417
1418 reuse_map
1419 }
1420
1421 fn reusable_vector_for_chunk(
1422 reuse_map: &ChunkReuseMap,
1423 chunk: &SemanticChunk,
1424 ) -> Option<Vec<f32>> {
1425 let hash = blake3::hash(chunk.embed_text.as_bytes());
1426 reuse_map
1427 .get(&chunk.file)?
1428 .get(&hash)?
1429 .iter()
1430 .find(|candidate| candidate.embed_text == chunk.embed_text)
1431 .map(|candidate| candidate.vector.clone())
1432 }
1433
1434 fn entries_for_chunks_with_reuse<F, P>(
1435 chunks: Vec<SemanticChunk>,
1436 reuse_map: &ChunkReuseMap,
1437 embed_fn: &mut F,
1438 max_batch_size: usize,
1439 initial_observed_dimension: Option<usize>,
1440 refresh_label: &str,
1441 progress: &mut P,
1442 ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
1443 where
1444 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1445 P: FnMut(usize, usize),
1446 {
1447 let total_chunks = chunks.len();
1448 progress(0, total_chunks);
1449
1450 let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
1451 let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
1452
1453 for (chunk_index, chunk) in chunks.into_iter().enumerate() {
1454 if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
1455 entries_by_chunk[chunk_index] = Some(EmbeddingEntry { chunk, vector });
1456 } else {
1457 misses.push((chunk_index, chunk));
1458 }
1459 }
1460
1461 let mut completed = total_chunks.saturating_sub(misses.len());
1462 if completed > 0 {
1463 progress(completed, total_chunks);
1464 }
1465
1466 let batch_size = max_batch_size.max(1);
1467 let mut observed_dimension = initial_observed_dimension;
1468
1469 for batch_start in (0..misses.len()).step_by(batch_size) {
1470 let batch_end = (batch_start + batch_size).min(misses.len());
1471 let batch_texts: Vec<String> = misses[batch_start..batch_end]
1472 .iter()
1473 .map(|(_, chunk)| chunk.embed_text.clone())
1474 .collect();
1475
1476 let vectors = embed_fn(batch_texts)?;
1477 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
1478
1479 if let Some(dim) = vectors.first().map(|vector| vector.len()) {
1480 match observed_dimension {
1481 None => observed_dimension = Some(dim),
1482 Some(expected) if dim != expected => {
1483 return Err(format!(
1484 "embedding dimension changed during {refresh_label}: \
1485 cached index uses {expected}, new vectors use {dim}"
1486 ));
1487 }
1488 _ => {}
1489 }
1490 }
1491
1492 for (i, vector) in vectors.into_iter().enumerate() {
1493 let (chunk_index, chunk) = misses[batch_start + i].clone();
1494 entries_by_chunk[chunk_index] = Some(EmbeddingEntry { chunk, vector });
1495 }
1496
1497 completed += batch_end - batch_start;
1498 progress(completed, total_chunks);
1499 }
1500
1501 let entries = entries_by_chunk
1502 .into_iter()
1503 .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
1504 .collect();
1505
1506 Ok((entries, observed_dimension))
1507 }
1508
1509 fn build_from_chunks<F, P>(
1510 project_root: &Path,
1511 chunks: Vec<SemanticChunk>,
1512 file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
1513 embed_fn: &mut F,
1514 max_batch_size: usize,
1515 mut progress: Option<&mut P>,
1516 ) -> Result<Self, String>
1517 where
1518 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1519 P: FnMut(usize, usize),
1520 {
1521 debug_assert!(project_root.is_absolute());
1522 let total_chunks = chunks.len();
1523
1524 if chunks.is_empty() {
1525 return Ok(Self {
1526 entries: Vec::new(),
1527 file_mtimes: file_metadata
1528 .iter()
1529 .map(|(path, metadata)| (path.clone(), metadata.mtime))
1530 .collect(),
1531 file_sizes: file_metadata
1532 .iter()
1533 .map(|(path, metadata)| (path.clone(), metadata.size))
1534 .collect(),
1535 file_hashes: file_metadata
1536 .into_iter()
1537 .map(|(path, metadata)| (path, metadata.content_hash))
1538 .collect(),
1539 dimension: DEFAULT_DIMENSION,
1540 fingerprint: None,
1541 project_root: project_root.to_path_buf(),
1542 deferred_files: HashSet::new(),
1543 });
1544 }
1545
1546 let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
1548 let mut expected_dimension: Option<usize> = None;
1549 let batch_size = max_batch_size.max(1);
1550 let embed_started = std::time::Instant::now();
1551 let batch_count = total_chunks.div_ceil(batch_size);
1552 for batch_start in (0..chunks.len()).step_by(batch_size) {
1553 let batch_end = (batch_start + batch_size).min(chunks.len());
1554 let batch_texts: Vec<String> = chunks[batch_start..batch_end]
1555 .iter()
1556 .map(|c| c.embed_text.clone())
1557 .collect();
1558
1559 let vectors = embed_fn(batch_texts)?;
1560 validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
1561
1562 if let Some(dim) = vectors.first().map(|v| v.len()) {
1564 match expected_dimension {
1565 None => expected_dimension = Some(dim),
1566 Some(expected) if dim != expected => {
1567 return Err(format!(
1568 "embedding dimension changed across batches: expected {expected}, got {dim}"
1569 ));
1570 }
1571 _ => {}
1572 }
1573 }
1574
1575 for (i, vector) in vectors.into_iter().enumerate() {
1576 let chunk_idx = batch_start + i;
1577 entries.push(EmbeddingEntry {
1578 chunk: chunks[chunk_idx].clone(),
1579 vector,
1580 });
1581 }
1582
1583 if let Some(callback) = progress.as_mut() {
1584 callback(entries.len(), total_chunks);
1585 }
1586 }
1587
1588 let embed_ms = embed_started.elapsed().as_millis();
1589 let rate = (total_chunks as u128 * 1000)
1590 .checked_div(embed_ms)
1591 .unwrap_or(0) as u64;
1592 slog_info!(
1593 "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
1594 total_chunks,
1595 batch_count,
1596 embed_ms,
1597 rate
1598 );
1599
1600 let dimension = entries
1601 .first()
1602 .map(|e| e.vector.len())
1603 .unwrap_or(DEFAULT_DIMENSION);
1604
1605 Ok(Self {
1606 entries,
1607 file_mtimes: file_metadata
1608 .iter()
1609 .map(|(path, metadata)| (path.clone(), metadata.mtime))
1610 .collect(),
1611 file_sizes: file_metadata
1612 .iter()
1613 .map(|(path, metadata)| (path.clone(), metadata.size))
1614 .collect(),
1615 file_hashes: file_metadata
1616 .into_iter()
1617 .map(|(path, metadata)| (path, metadata.content_hash))
1618 .collect(),
1619 dimension,
1620 fingerprint: None,
1621 project_root: project_root.to_path_buf(),
1622 deferred_files: HashSet::new(),
1623 })
1624 }
1625
1626 pub fn build<F>(
1629 project_root: &Path,
1630 files: &[PathBuf],
1631 embed_fn: &mut F,
1632 max_batch_size: usize,
1633 ) -> Result<Self, String>
1634 where
1635 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1636 {
1637 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
1638 Self::build_from_chunks(
1639 project_root,
1640 chunks,
1641 file_mtimes,
1642 embed_fn,
1643 max_batch_size,
1644 Option::<&mut fn(usize, usize)>::None,
1645 )
1646 }
1647
1648 pub fn build_with_progress<F, P>(
1650 project_root: &Path,
1651 files: &[PathBuf],
1652 embed_fn: &mut F,
1653 max_batch_size: usize,
1654 progress: &mut P,
1655 ) -> Result<Self, String>
1656 where
1657 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1658 P: FnMut(usize, usize),
1659 {
1660 let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
1661 let total_chunks = chunks.len();
1662 progress(0, total_chunks);
1663 Self::build_from_chunks(
1664 project_root,
1665 chunks,
1666 file_mtimes,
1667 embed_fn,
1668 max_batch_size,
1669 Some(progress),
1670 )
1671 }
1672
1673 pub fn refresh_stale_files<F, P>(
1684 &mut self,
1685 project_root: &Path,
1686 current_files: &[PathBuf],
1687 embed_fn: &mut F,
1688 max_batch_size: usize,
1689 progress: &mut P,
1690 ) -> Result<RefreshSummary, String>
1691 where
1692 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1693 P: FnMut(usize, usize),
1694 {
1695 self.backfill_missing_file_sizes();
1696
1697 let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
1699 self.deferred_files
1700 .retain(|path| current_set.contains(path.as_path()));
1701 let total_processed = current_set.len() + self.file_mtimes.len()
1702 - self
1703 .file_mtimes
1704 .keys()
1705 .filter(|path| current_set.contains(path.as_path()))
1706 .count();
1707
1708 enum IndexedFileCheck {
1711 Deleted(PathBuf),
1712 MissingMetadata(PathBuf),
1713 Verified(PathBuf, FreshnessVerdict),
1714 }
1715
1716 let mut deleted: Vec<PathBuf> = Vec::new();
1717 let mut changed: Vec<PathBuf> = Vec::new();
1718 let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
1719 let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
1720 let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
1721
1722 for indexed_path in indexed_paths {
1723 let check_index = checks.len();
1724 if !current_set.contains(indexed_path.as_path()) {
1725 checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
1726 continue;
1727 }
1728 let cached = match (
1729 self.file_mtimes.get(&indexed_path),
1730 self.file_sizes.get(&indexed_path),
1731 self.file_hashes.get(&indexed_path),
1732 ) {
1733 (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
1734 mtime: *mtime,
1735 size: *size,
1736 content_hash: *hash,
1737 }),
1738 _ => None,
1739 };
1740 if let Some(freshness) = cached {
1741 strict_verify_inputs.push((check_index, indexed_path, freshness));
1742 checks.push(None);
1743 } else {
1744 checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
1745 }
1746 }
1747
1748 for (check_index, path, verdict) in
1749 cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
1750 {
1751 checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
1752 }
1753
1754 for check in checks {
1755 match check.expect("strict freshness check should be populated") {
1756 IndexedFileCheck::Deleted(path) => deleted.push(path),
1757 IndexedFileCheck::MissingMetadata(path) => changed.push(path),
1758 IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
1759 IndexedFileCheck::Verified(
1760 path,
1761 FreshnessVerdict::ContentFresh {
1762 new_mtime,
1763 new_size,
1764 },
1765 ) => {
1766 self.file_mtimes.insert(path.clone(), new_mtime);
1767 self.file_sizes.insert(path, new_size);
1768 }
1769 IndexedFileCheck::Verified(
1770 path,
1771 FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
1772 ) => {
1773 changed.push(path);
1774 }
1775 }
1776 }
1777
1778 let mut added: Vec<PathBuf> = Vec::new();
1780 for path in current_files {
1781 if !self.file_mtimes.contains_key(path) {
1782 added.push(path.clone());
1783 }
1784 }
1785
1786 if deleted.is_empty() && changed.is_empty() && added.is_empty() {
1788 progress(0, 0);
1789 return Ok(RefreshSummary {
1790 total_processed,
1791 ..RefreshSummary::default()
1792 });
1793 }
1794
1795 if !deleted.is_empty() {
1799 self.remove_indexed_files(&deleted);
1800 }
1801
1802 let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
1804 to_embed.extend(changed.iter().cloned());
1805 to_embed.extend(added.iter().cloned());
1806
1807 if to_embed.is_empty() {
1808 progress(0, 0);
1810 return Ok(RefreshSummary {
1811 changed: 0,
1812 added: 0,
1813 deleted: deleted.len(),
1814 total_processed,
1815 });
1816 }
1817
1818 let reuse_map = self.build_chunk_reuse_map(&changed);
1819 let (chunks, fresh_metadata) = Self::collect_chunks(project_root, &to_embed);
1820 let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
1821 let vanished = to_embed
1822 .iter()
1823 .filter(|path| {
1824 changed_set.contains(path.as_path())
1825 && !fresh_metadata.contains_key(*path)
1826 && !path.exists()
1827 })
1828 .cloned()
1829 .collect::<Vec<_>>();
1830 if !vanished.is_empty() {
1831 self.remove_indexed_files(&vanished);
1832 deleted.extend(vanished);
1833 }
1834
1835 if chunks.is_empty() {
1836 progress(0, 0);
1837 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
1838 for file in &successful_files {
1839 self.deferred_files.remove(file);
1840 }
1841 if !successful_files.is_empty() {
1842 self.entries
1843 .retain(|entry| !successful_files.contains(&entry.chunk.file));
1844 }
1845 let changed_count = changed
1846 .iter()
1847 .filter(|path| successful_files.contains(*path))
1848 .count();
1849 let added_count = added
1850 .iter()
1851 .filter(|path| successful_files.contains(*path))
1852 .count();
1853 for (file, metadata) in fresh_metadata {
1854 self.file_mtimes.insert(file.clone(), metadata.mtime);
1855 self.file_sizes.insert(file.clone(), metadata.size);
1856 self.file_hashes.insert(file.clone(), metadata.content_hash);
1857 }
1858 return Ok(RefreshSummary {
1859 changed: changed_count,
1860 added: added_count,
1861 deleted: deleted.len(),
1862 total_processed,
1863 });
1864 }
1865
1866 let existing_dimension = if self.entries.is_empty() {
1869 None
1870 } else {
1871 Some(self.dimension)
1872 };
1873 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
1874 chunks,
1875 &reuse_map,
1876 embed_fn,
1877 max_batch_size,
1878 existing_dimension,
1879 "incremental refresh",
1880 progress,
1881 )?;
1882
1883 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
1884 for file in &successful_files {
1885 self.deferred_files.remove(file);
1886 }
1887 if !successful_files.is_empty() {
1888 self.entries
1889 .retain(|entry| !successful_files.contains(&entry.chunk.file));
1890 }
1891
1892 self.entries.extend(new_entries);
1893 for (file, metadata) in fresh_metadata {
1894 self.file_mtimes.insert(file.clone(), metadata.mtime);
1895 self.file_sizes.insert(file.clone(), metadata.size);
1896 self.file_hashes.insert(file, metadata.content_hash);
1897 }
1898 if let Some(dim) = observed_dimension {
1899 self.dimension = dim;
1900 }
1901
1902 Ok(RefreshSummary {
1903 changed: changed
1904 .iter()
1905 .filter(|path| successful_files.contains(*path))
1906 .count(),
1907 added: added
1908 .iter()
1909 .filter(|path| successful_files.contains(*path))
1910 .count(),
1911 deleted: deleted.len(),
1912 total_processed,
1913 })
1914 }
1915
1916 pub fn refresh_invalidated_files<F, P>(
1923 &mut self,
1924 project_root: &Path,
1925 paths: &[PathBuf],
1926 embed_fn: &mut F,
1927 max_batch_size: usize,
1928 max_files: usize,
1929 progress: &mut P,
1930 ) -> Result<InvalidatedFilesRefresh, String>
1931 where
1932 F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
1933 P: FnMut(usize, usize),
1934 {
1935 self.backfill_missing_file_sizes();
1936
1937 self.deferred_files.retain(|path| path.exists());
1938 let mut requested_paths = paths.to_vec();
1939 requested_paths.extend(self.deferred_files.iter().cloned());
1940 requested_paths.sort();
1941 requested_paths.dedup();
1942 let total_processed = requested_paths.len();
1943
1944 if requested_paths.is_empty() {
1945 progress(0, 0);
1946 return Ok(InvalidatedFilesRefresh {
1947 summary: RefreshSummary {
1948 total_processed,
1949 ..RefreshSummary::default()
1950 },
1951 ..InvalidatedFilesRefresh::default()
1952 });
1953 }
1954
1955 let previously_indexed: HashSet<PathBuf> = requested_paths
1956 .iter()
1957 .filter(|path| self.file_mtimes.contains_key(*path))
1958 .cloned()
1959 .collect();
1960 let reuse_map = self.build_chunk_reuse_map(&requested_paths);
1961
1962 self.remove_indexed_files(&requested_paths);
1966
1967 let existing_paths = requested_paths
1968 .iter()
1969 .filter(|path| path.exists())
1970 .cloned()
1971 .collect::<Vec<_>>();
1972 let deleted = requested_paths
1973 .iter()
1974 .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
1975 .count();
1976
1977 if existing_paths.is_empty() {
1978 for path in &requested_paths {
1979 if !path.exists() {
1980 self.deferred_files.remove(path);
1981 }
1982 }
1983 progress(0, 0);
1984 return Ok(InvalidatedFilesRefresh {
1985 completed_paths: requested_paths,
1986 summary: RefreshSummary {
1987 deleted,
1988 total_processed,
1989 ..RefreshSummary::default()
1990 },
1991 ..InvalidatedFilesRefresh::default()
1992 });
1993 }
1994
1995 let (mut chunks, mut fresh_metadata) = Self::collect_chunks(project_root, &existing_paths);
1996
1997 let retained_file_count = self.file_mtimes.len();
1998 let changed_successful_count = existing_paths
1999 .iter()
2000 .filter(|path| {
2001 previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2002 })
2003 .count();
2004 let available_new_files =
2005 max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
2006 let new_successful_files = existing_paths
2007 .iter()
2008 .filter(|path| {
2009 !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2010 })
2011 .cloned()
2012 .collect::<Vec<_>>();
2013 if new_successful_files.len() > available_new_files {
2014 let allowed_new_files = new_successful_files
2015 .iter()
2016 .take(available_new_files)
2017 .cloned()
2018 .collect::<HashSet<_>>();
2019 let deferred_new_files = new_successful_files
2020 .into_iter()
2021 .filter(|path| !allowed_new_files.contains(path))
2022 .collect::<HashSet<_>>();
2023
2024 fresh_metadata.retain(|file, _| {
2025 previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
2026 });
2027 chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
2028
2029 if !deferred_new_files.is_empty() {
2030 for path in &deferred_new_files {
2031 self.deferred_files.insert(path.clone());
2032 }
2033 slog_warn!(
2034 "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
2035 deferred_new_files.len(),
2036 max_files
2037 );
2038 }
2039 }
2040
2041 let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2042 for file in &successful_files {
2043 self.deferred_files.remove(file);
2044 }
2045 let changed = successful_files
2046 .iter()
2047 .filter(|path| previously_indexed.contains(path.as_path()))
2048 .count();
2049 let added = successful_files.len().saturating_sub(changed);
2050 let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
2051
2052 if chunks.is_empty() {
2053 progress(0, 0);
2054 for (file, metadata) in fresh_metadata {
2055 let freshness = FileFreshness {
2056 mtime: metadata.mtime,
2057 size: metadata.size,
2058 content_hash: metadata.content_hash,
2059 };
2060 self.file_mtimes.insert(file.clone(), freshness.mtime);
2061 self.file_sizes.insert(file.clone(), freshness.size);
2062 self.file_hashes
2063 .insert(file.clone(), freshness.content_hash);
2064 updated_metadata.push((file, freshness));
2065 }
2066
2067 return Ok(InvalidatedFilesRefresh {
2068 updated_metadata,
2069 completed_paths: requested_paths,
2070 summary: RefreshSummary {
2071 changed,
2072 added,
2073 deleted,
2074 total_processed,
2075 },
2076 ..InvalidatedFilesRefresh::default()
2077 });
2078 }
2079
2080 let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
2081 {
2082 None
2083 } else {
2084 Some(self.dimension)
2085 };
2086 let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
2087 chunks,
2088 &reuse_map,
2089 embed_fn,
2090 max_batch_size,
2091 initial_observed_dimension,
2092 "invalidated-file refresh",
2093 progress,
2094 )?;
2095
2096 let added_entries = new_entries.clone();
2097 self.entries.extend(new_entries);
2098 for (file, metadata) in fresh_metadata {
2099 let freshness = FileFreshness {
2100 mtime: metadata.mtime,
2101 size: metadata.size,
2102 content_hash: metadata.content_hash,
2103 };
2104 self.file_mtimes.insert(file.clone(), freshness.mtime);
2105 self.file_sizes.insert(file.clone(), freshness.size);
2106 self.file_hashes
2107 .insert(file.clone(), freshness.content_hash);
2108 updated_metadata.push((file, freshness));
2109 }
2110 if let Some(dim) = observed_dimension {
2111 self.dimension = dim;
2112 }
2113
2114 Ok(InvalidatedFilesRefresh {
2115 added_entries,
2116 updated_metadata,
2117 completed_paths: requested_paths,
2118 summary: RefreshSummary {
2119 changed,
2120 added,
2121 deleted,
2122 total_processed,
2123 },
2124 })
2125 }
2126
2127 pub fn apply_refresh_update(
2128 &mut self,
2129 added_entries: Vec<EmbeddingEntry>,
2130 updated_metadata: Vec<(PathBuf, FileFreshness)>,
2131 completed_paths: &[PathBuf],
2132 ) {
2133 self.remove_indexed_files(completed_paths);
2137
2138 let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
2139 self.entries.extend(added_entries);
2140 for (file, freshness) in updated_metadata {
2141 self.file_mtimes.insert(file.clone(), freshness.mtime);
2142 self.file_sizes.insert(file.clone(), freshness.size);
2143 self.file_hashes.insert(file, freshness.content_hash);
2144 }
2145 if let Some(dim) = observed_dimension {
2146 self.dimension = dim;
2147 }
2148 }
2149
2150 fn remove_indexed_files(&mut self, files: &[PathBuf]) {
2151 let deleted_set: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2152 self.entries
2153 .retain(|entry| !deleted_set.contains(entry.chunk.file.as_path()));
2154 for path in files {
2155 self.file_mtimes.remove(path);
2156 self.file_sizes.remove(path);
2157 self.file_hashes.remove(path);
2158 }
2159 }
2160
2161 pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
2163 if self.entries.is_empty() || query_vector.len() != self.dimension {
2164 return Vec::new();
2165 }
2166
2167 let mut scored: Vec<(f32, usize)> = self
2168 .entries
2169 .iter()
2170 .enumerate()
2171 .map(|(i, entry)| {
2172 let mut score = cosine_similarity(query_vector, &entry.vector);
2173 if entry.chunk.exported {
2174 score *= 1.1;
2175 }
2176 (score, i)
2177 })
2178 .collect();
2179
2180 let keep = top_k.min(scored.len());
2181 if keep == 0 {
2182 return Vec::new();
2183 }
2184
2185 if keep < scored.len() {
2186 scored.select_nth_unstable_by(keep, semantic_score_order);
2187 scored.truncate(keep);
2188 }
2189 scored.sort_by(semantic_score_order);
2190
2191 scored
2192 .into_iter()
2193 .map(|(score, idx)| {
2197 let entry = &self.entries[idx];
2198 SemanticResult {
2199 file: entry.chunk.file.clone(),
2200 name: entry.chunk.name.clone(),
2201 qualified_name: entry.chunk.qualified_name.clone(),
2202 kind: entry.chunk.kind.clone(),
2203 start_line: entry.chunk.start_line,
2204 end_line: entry.chunk.end_line,
2205 exported: entry.chunk.exported,
2206 snippet: entry.chunk.snippet.clone(),
2207 score,
2208 rank_score: score,
2209 cap_protected: false,
2210 source: "semantic",
2211 }
2212 })
2213 .collect()
2214 }
2215
2216 pub fn len(&self) -> usize {
2218 self.entries.len()
2219 }
2220
2221 pub fn is_file_stale(&self, file: &Path) -> bool {
2223 let Some(stored_mtime) = self.file_mtimes.get(file) else {
2224 return true;
2225 };
2226 let Some(stored_size) = self.file_sizes.get(file) else {
2227 return true;
2228 };
2229 let Some(stored_hash) = self.file_hashes.get(file) else {
2230 return true;
2231 };
2232 let cached = FileFreshness {
2233 mtime: *stored_mtime,
2234 size: *stored_size,
2235 content_hash: *stored_hash,
2236 };
2237 match cache_freshness::verify_file_strict(file, &cached) {
2238 FreshnessVerdict::HotFresh => false,
2239 FreshnessVerdict::ContentFresh { .. } => false,
2240 FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
2241 }
2242 }
2243
2244 fn backfill_missing_file_sizes(&mut self) {
2245 for path in self.file_mtimes.keys() {
2246 if self.file_sizes.contains_key(path) {
2247 continue;
2248 }
2249 if let Ok(metadata) = fs::metadata(path) {
2250 self.file_sizes.insert(path.clone(), metadata.len());
2251 if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
2252 self.file_hashes.insert(path.clone(), hash);
2253 }
2254 }
2255 }
2256 }
2257
2258 pub fn remove_file(&mut self, file: &Path) {
2260 self.invalidate_file(file);
2261 }
2262
2263 pub fn invalidate_file(&mut self, file: &Path) {
2264 let canonical_file = canonicalize_existing_or_deleted_path(file);
2265 self.entries
2266 .retain(|e| e.chunk.file != file && e.chunk.file != canonical_file);
2267 self.file_mtimes.remove(file);
2268 self.file_sizes.remove(file);
2269 self.file_hashes.remove(file);
2270 if canonical_file.as_path() != file {
2271 self.file_mtimes.remove(&canonical_file);
2272 self.file_sizes.remove(&canonical_file);
2273 self.file_hashes.remove(&canonical_file);
2274 }
2275 }
2276
2277 pub fn dimension(&self) -> usize {
2279 self.dimension
2280 }
2281
2282 pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
2283 self.fingerprint.as_ref()
2284 }
2285
2286 pub(crate) fn indexed_file_metadata(&self) -> Vec<(PathBuf, SystemTime, u64, blake3::Hash)> {
2287 self.file_mtimes
2288 .iter()
2289 .filter_map(|(path, mtime)| {
2290 let size = self.file_sizes.get(path)?;
2291 let content_hash = self.file_hashes.get(path)?;
2292 Some((path.clone(), *mtime, *size, *content_hash))
2293 })
2294 .collect()
2295 }
2296
2297 pub(crate) fn indexed_file_paths(&self) -> HashSet<PathBuf> {
2298 self.file_mtimes.keys().cloned().collect()
2299 }
2300
2301 pub fn backend_label(&self) -> Option<&str> {
2302 self.fingerprint.as_ref().map(|f| f.backend.as_str())
2303 }
2304
2305 pub fn model_label(&self) -> Option<&str> {
2306 self.fingerprint.as_ref().map(|f| f.model.as_str())
2307 }
2308
2309 pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
2310 self.fingerprint = Some(fingerprint);
2311 }
2312
2313 pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) {
2315 if self.entries.is_empty() {
2318 slog_info!("skipping semantic index persistence (0 entries)");
2319 return;
2320 }
2321 let dir = storage_dir.join("semantic").join(project_key);
2322 if let Err(e) = fs::create_dir_all(&dir) {
2323 slog_warn!("failed to create semantic cache dir: {}", e);
2324 return;
2325 }
2326 let data_path = dir.join("semantic.bin");
2327 let tmp_path = dir.join(format!(
2328 "semantic.bin.tmp.{}.{}",
2329 std::process::id(),
2330 SystemTime::now()
2331 .duration_since(SystemTime::UNIX_EPOCH)
2332 .unwrap_or(Duration::ZERO)
2333 .as_nanos()
2334 ));
2335 let write_result = (|| -> io::Result<usize> {
2336 let file = fs::File::create(&tmp_path)?;
2337 let mut writer = BufWriter::new(file);
2338 let bytes_written = self.write_to_writer(&mut writer)?;
2339 writer.flush()?;
2340 writer.get_ref().sync_all()?;
2341 Ok(bytes_written)
2342 })();
2343 let bytes_written = match write_result {
2344 Ok(bytes_written) => bytes_written,
2345 Err(e) => {
2346 slog_warn!("failed to write semantic index: {}", e);
2347 let _ = fs::remove_file(&tmp_path);
2348 return;
2349 }
2350 };
2351 if let Err(e) = fs::rename(&tmp_path, &data_path) {
2352 slog_warn!("failed to rename semantic index: {}", e);
2353 let _ = fs::remove_file(&tmp_path);
2354 return;
2355 }
2356 slog_info!(
2357 "semantic index persisted: {} entries, {:.1} KB",
2358 self.entries.len(),
2359 bytes_written as f64 / 1024.0
2360 );
2361 }
2362
2363 pub fn read_from_disk(
2365 storage_dir: &Path,
2366 project_key: &str,
2367 current_canonical_root: &Path,
2368 is_worktree_bridge: bool,
2369 expected_fingerprint: Option<&str>,
2370 ) -> Option<Self> {
2371 debug_assert!(current_canonical_root.is_absolute());
2372 let data_path = storage_dir
2373 .join("semantic")
2374 .join(project_key)
2375 .join("semantic.bin");
2376 let file = fs::File::open(&data_path).ok()?;
2377 let file_len = usize::try_from(file.metadata().ok()?.len()).ok()?;
2378 if file_len < HEADER_BYTES_V1 {
2379 slog_warn!(
2380 "corrupt semantic index (too small: {} bytes), removing",
2381 file_len
2382 );
2383 if !is_worktree_bridge {
2384 let _ = fs::remove_file(&data_path);
2385 }
2386 return None;
2387 }
2388
2389 let mut reader = BufReader::new(file);
2390 let mut version_buf = [0u8; 1];
2391 reader.read_exact(&mut version_buf).ok()?;
2392 let version = version_buf[0];
2393 if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
2394 slog_info!(
2395 "cached semantic index version {} is not compatible with {}, rebuilding",
2396 version,
2397 SEMANTIC_INDEX_VERSION_V7
2398 );
2399 if !is_worktree_bridge {
2400 let _ = fs::remove_file(&data_path);
2401 }
2402 return None;
2403 }
2404 match Self::from_reader_after_version(
2405 reader,
2406 version,
2407 current_canonical_root,
2408 Some(file_len),
2409 1,
2410 ) {
2411 Ok(index) => {
2412 if index.entries.is_empty() {
2413 slog_info!("cached semantic index is empty, will rebuild");
2414 if !is_worktree_bridge {
2415 let _ = fs::remove_file(&data_path);
2416 }
2417 return None;
2418 }
2419 if let Some(expected) = expected_fingerprint {
2420 let matches = index
2421 .fingerprint()
2422 .map(|fingerprint| fingerprint.matches_expected(expected))
2423 .unwrap_or(false);
2424 if !matches {
2425 slog_info!("cached semantic index fingerprint mismatch, rebuilding");
2426 if !is_worktree_bridge {
2427 let _ = fs::remove_file(&data_path);
2428 }
2429 return None;
2430 }
2431 }
2432 slog_info!(
2433 "loaded semantic index from disk: {} entries",
2434 index.entries.len()
2435 );
2436 Some(index)
2437 }
2438 Err(e) => {
2439 slog_warn!("corrupt semantic index, rebuilding: {}", e);
2440 if !is_worktree_bridge {
2441 let _ = fs::remove_file(&data_path);
2442 }
2443 None
2444 }
2445 }
2446 }
2447
2448 pub(crate) fn read_from_disk_borrow_tolerant(
2449 storage_dir: &Path,
2450 project_key: &str,
2451 current_canonical_root: &Path,
2452 ) -> Option<Self> {
2453 Self::read_from_disk(storage_dir, project_key, current_canonical_root, true, None)
2454 }
2455
2456 pub fn to_bytes(&self) -> Vec<u8> {
2458 let mut buf = Vec::new();
2459 self.write_to_writer(&mut buf)
2460 .expect("writing semantic index to Vec cannot fail");
2461 buf
2462 }
2463
2464 fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
2465 let mut bytes_written = 0usize;
2466 let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
2467 let encoded = fingerprint.as_string();
2468 if encoded.is_empty() {
2469 None
2470 } else {
2471 Some(encoded)
2472 }
2473 });
2474 let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
2475 let file_mtime_count = self
2476 .file_mtimes
2477 .iter()
2478 .filter(|(path, _)| cache_relative_path(&self.project_root, path).is_some())
2479 .count();
2480 let entry_count = self
2481 .entries
2482 .iter()
2483 .filter(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
2484 .count();
2485
2486 let version = SEMANTIC_INDEX_VERSION_V7;
2501 write_counted(writer, &[version], &mut bytes_written)?;
2502 write_counted(
2503 writer,
2504 &(self.dimension as u32).to_le_bytes(),
2505 &mut bytes_written,
2506 )?;
2507 write_counted(
2508 writer,
2509 &(entry_count as u32).to_le_bytes(),
2510 &mut bytes_written,
2511 )?;
2512 write_counted(
2513 writer,
2514 &(fp_bytes_ref.len() as u32).to_le_bytes(),
2515 &mut bytes_written,
2516 )?;
2517 write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
2518
2519 write_counted(
2522 writer,
2523 &(file_mtime_count as u32).to_le_bytes(),
2524 &mut bytes_written,
2525 )?;
2526 for (path, mtime) in &self.file_mtimes {
2527 let Some(relative) = cache_relative_path(&self.project_root, path) else {
2528 continue;
2529 };
2530 let relative = relative.to_string_lossy();
2531 let path_bytes = relative.as_bytes();
2532 write_counted(
2533 writer,
2534 &(path_bytes.len() as u32).to_le_bytes(),
2535 &mut bytes_written,
2536 )?;
2537 write_counted(writer, path_bytes, &mut bytes_written)?;
2538 let duration = mtime
2539 .duration_since(SystemTime::UNIX_EPOCH)
2540 .unwrap_or_default();
2541 write_counted(
2542 writer,
2543 &duration.as_secs().to_le_bytes(),
2544 &mut bytes_written,
2545 )?;
2546 write_counted(
2547 writer,
2548 &duration.subsec_nanos().to_le_bytes(),
2549 &mut bytes_written,
2550 )?;
2551 let size = self.file_sizes.get(path).copied().unwrap_or_default();
2552 write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
2553 let hash = self
2554 .file_hashes
2555 .get(path)
2556 .copied()
2557 .unwrap_or_else(cache_freshness::zero_hash);
2558 write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
2559 }
2560
2561 for entry in &self.entries {
2563 let Some(relative) = cache_relative_path(&self.project_root, &entry.chunk.file) else {
2564 continue;
2565 };
2566 let c = &entry.chunk;
2567
2568 let relative = relative.to_string_lossy();
2570 let file_bytes = relative.as_bytes();
2571 write_counted(
2572 writer,
2573 &(file_bytes.len() as u32).to_le_bytes(),
2574 &mut bytes_written,
2575 )?;
2576 write_counted(writer, file_bytes, &mut bytes_written)?;
2577
2578 let name_bytes = c.name.as_bytes();
2580 write_counted(
2581 writer,
2582 &(name_bytes.len() as u32).to_le_bytes(),
2583 &mut bytes_written,
2584 )?;
2585 write_counted(writer, name_bytes, &mut bytes_written)?;
2586
2587 let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
2589 write_counted(
2590 writer,
2591 &(qualified_name_bytes.len() as u32).to_le_bytes(),
2592 &mut bytes_written,
2593 )?;
2594 write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
2595
2596 write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
2598
2599 write_counted(
2601 writer,
2602 &(c.start_line as u32).to_le_bytes(),
2603 &mut bytes_written,
2604 )?;
2605 write_counted(
2606 writer,
2607 &(c.end_line as u32).to_le_bytes(),
2608 &mut bytes_written,
2609 )?;
2610 write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
2611
2612 let snippet_bytes = c.snippet.as_bytes();
2614 write_counted(
2615 writer,
2616 &(snippet_bytes.len() as u32).to_le_bytes(),
2617 &mut bytes_written,
2618 )?;
2619 write_counted(writer, snippet_bytes, &mut bytes_written)?;
2620
2621 let embed_bytes = c.embed_text.as_bytes();
2623 write_counted(
2624 writer,
2625 &(embed_bytes.len() as u32).to_le_bytes(),
2626 &mut bytes_written,
2627 )?;
2628 write_counted(writer, embed_bytes, &mut bytes_written)?;
2629
2630 for &val in &entry.vector {
2632 write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
2633 }
2634 }
2635
2636 Ok(bytes_written)
2637 }
2638
2639 pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
2641 debug_assert!(current_canonical_root.is_absolute());
2642 if data.len() < HEADER_BYTES_V1 {
2643 return Err("data too short".to_string());
2644 }
2645
2646 Self::from_reader_after_version(
2647 Cursor::new(&data[1..]),
2648 data[0],
2649 current_canonical_root,
2650 Some(data.len()),
2651 1,
2652 )
2653 }
2654
2655 fn from_reader_after_version<R: Read>(
2656 reader: R,
2657 version: u8,
2658 current_canonical_root: &Path,
2659 total_len: Option<usize>,
2660 bytes_read: usize,
2661 ) -> Result<Self, String> {
2662 debug_assert!(current_canonical_root.is_absolute());
2663 let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
2664
2665 if version != SEMANTIC_INDEX_VERSION_V1
2666 && version != SEMANTIC_INDEX_VERSION_V2
2667 && version != SEMANTIC_INDEX_VERSION_V3
2668 && version != SEMANTIC_INDEX_VERSION_V4
2669 && version != SEMANTIC_INDEX_VERSION_V5
2670 && version != SEMANTIC_INDEX_VERSION_V6
2671 && version != SEMANTIC_INDEX_VERSION_V7
2672 {
2673 return Err(format!("unsupported version: {}", version));
2674 }
2675 if (version == SEMANTIC_INDEX_VERSION_V2
2679 || version == SEMANTIC_INDEX_VERSION_V3
2680 || version == SEMANTIC_INDEX_VERSION_V4
2681 || version == SEMANTIC_INDEX_VERSION_V5
2682 || version == SEMANTIC_INDEX_VERSION_V6
2683 || version == SEMANTIC_INDEX_VERSION_V7)
2684 && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
2685 {
2686 return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
2687 }
2688
2689 let dimension = read_u32_stream(&mut reader)? as usize;
2690 let entry_count = read_u32_stream(&mut reader)? as usize;
2691 validate_embedding_dimension(dimension)?;
2692 if entry_count > MAX_ENTRIES {
2693 return Err(format!("too many semantic index entries: {}", entry_count));
2694 }
2695
2696 let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
2702 || version == SEMANTIC_INDEX_VERSION_V3
2703 || version == SEMANTIC_INDEX_VERSION_V4
2704 || version == SEMANTIC_INDEX_VERSION_V5
2705 || version == SEMANTIC_INDEX_VERSION_V6
2706 || version == SEMANTIC_INDEX_VERSION_V7;
2707 let fingerprint = if has_fingerprint_field {
2708 let fingerprint_len = read_u32_stream(&mut reader)? as usize;
2709 if total_len
2710 .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
2711 {
2712 return Err("unexpected end of data reading fingerprint".to_string());
2713 }
2714 if fingerprint_len == 0 {
2715 None
2716 } else {
2717 let mut raw = vec![0u8; fingerprint_len];
2718 read_exact_stream(
2719 &mut reader,
2720 &mut raw,
2721 "unexpected end of data reading fingerprint",
2722 )?;
2723 let raw = String::from_utf8_lossy(&raw).to_string();
2724 Some(
2725 serde_json::from_str::<SemanticIndexFingerprint>(&raw)
2726 .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
2727 )
2728 }
2729 } else {
2730 None
2731 };
2732
2733 let mtime_count = read_u32_stream(&mut reader)? as usize;
2735 if mtime_count > MAX_ENTRIES {
2736 return Err(format!("too many semantic file mtimes: {}", mtime_count));
2737 }
2738
2739 let vector_bytes = entry_count
2740 .checked_mul(dimension)
2741 .and_then(|count| count.checked_mul(F32_BYTES))
2742 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
2743 if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
2744 return Err("semantic index vectors exceed available data".to_string());
2745 }
2746
2747 let mut file_mtimes = HashMap::with_capacity(mtime_count);
2748 let mut file_sizes = HashMap::with_capacity(mtime_count);
2749 let mut file_hashes = HashMap::with_capacity(mtime_count);
2750 for _ in 0..mtime_count {
2751 let path = read_string_stream(&mut reader, total_len)?;
2752 let secs = read_u64_stream(&mut reader)?;
2753 let nanos = if version == SEMANTIC_INDEX_VERSION_V3
2759 || version == SEMANTIC_INDEX_VERSION_V4
2760 || version == SEMANTIC_INDEX_VERSION_V5
2761 || version == SEMANTIC_INDEX_VERSION_V6
2762 || version == SEMANTIC_INDEX_VERSION_V7
2763 {
2764 read_u32_stream(&mut reader)?
2765 } else {
2766 0
2767 };
2768 let size = if version == SEMANTIC_INDEX_VERSION_V5
2769 || version == SEMANTIC_INDEX_VERSION_V6
2770 || version == SEMANTIC_INDEX_VERSION_V7
2771 {
2772 read_u64_stream(&mut reader)?
2773 } else {
2774 0
2775 };
2776 let content_hash =
2777 if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
2778 let mut hash_bytes = [0u8; 32];
2779 read_exact_stream(
2780 &mut reader,
2781 &mut hash_bytes,
2782 "unexpected end of data reading content hash",
2783 )?;
2784 blake3::Hash::from_bytes(hash_bytes)
2785 } else {
2786 cache_freshness::zero_hash()
2787 };
2788 if nanos >= 1_000_000_000 {
2795 return Err(format!(
2796 "invalid semantic mtime: nanos {} >= 1_000_000_000",
2797 nanos
2798 ));
2799 }
2800 let duration = std::time::Duration::new(secs, nanos);
2801 let mtime = SystemTime::UNIX_EPOCH
2802 .checked_add(duration)
2803 .ok_or_else(|| {
2804 format!(
2805 "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
2806 secs, nanos
2807 )
2808 })?;
2809 let path = if version == SEMANTIC_INDEX_VERSION_V6
2810 || version == SEMANTIC_INDEX_VERSION_V7
2811 {
2812 cached_path_under_root(current_canonical_root, &PathBuf::from(path))
2813 .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
2814 } else {
2815 PathBuf::from(path)
2816 };
2817 file_mtimes.insert(path.clone(), mtime);
2818 file_sizes.insert(path.clone(), size);
2819 file_hashes.insert(path, content_hash);
2820 }
2821
2822 let mut entries = Vec::with_capacity(entry_count);
2824 for _ in 0..entry_count {
2825 let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
2826 let file = if version == SEMANTIC_INDEX_VERSION_V6
2827 || version == SEMANTIC_INDEX_VERSION_V7
2828 {
2829 cached_path_under_root(current_canonical_root, &raw_file)
2830 .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
2831 } else {
2832 raw_file
2833 };
2834 let name = read_string_stream(&mut reader, total_len)?;
2835 let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
2836 let qualified_name = read_string_stream(&mut reader, total_len)?;
2837 if qualified_name.is_empty() {
2838 None
2839 } else {
2840 Some(qualified_name)
2841 }
2842 } else {
2843 None
2844 };
2845
2846 let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
2847
2848 let start_line = read_u32_stream(&mut reader)?;
2849 let end_line = read_u32_stream(&mut reader)?;
2850
2851 let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
2852
2853 let snippet = read_string_stream(&mut reader, total_len)?;
2854 let embed_text = read_string_stream(&mut reader, total_len)?;
2855
2856 let vec_bytes = dimension
2858 .checked_mul(F32_BYTES)
2859 .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
2860 if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
2861 return Err("unexpected end of data reading vector".to_string());
2862 }
2863 let mut vector = Vec::with_capacity(dimension);
2864 for _ in 0..dimension {
2865 let mut bytes = [0u8; F32_BYTES];
2866 read_exact_stream(
2867 &mut reader,
2868 &mut bytes,
2869 "unexpected end of data reading vector",
2870 )?;
2871 vector.push(f32::from_le_bytes(bytes));
2872 }
2873
2874 entries.push(EmbeddingEntry {
2875 chunk: SemanticChunk {
2876 file,
2877 name,
2878 qualified_name,
2879 kind,
2880 start_line,
2881 end_line,
2882 exported,
2883 embed_text,
2884 snippet,
2885 },
2886 vector,
2887 });
2888 }
2889
2890 if entries.len() != entry_count {
2891 return Err(format!(
2892 "semantic cache entry count drift: header={} decoded={}",
2893 entry_count,
2894 entries.len()
2895 ));
2896 }
2897 for entry in &entries {
2898 if !file_mtimes.contains_key(&entry.chunk.file) {
2899 return Err(format!(
2900 "semantic cache metadata missing for entry file {}",
2901 entry.chunk.file.display()
2902 ));
2903 }
2904 }
2905
2906 Ok(Self {
2907 entries,
2908 file_mtimes,
2909 file_sizes,
2910 file_hashes,
2911 dimension,
2912 fingerprint,
2913 project_root: current_canonical_root.to_path_buf(),
2914 deferred_files: HashSet::new(),
2915 })
2916 }
2917}
2918
2919fn write_counted<W: Write>(
2920 writer: &mut W,
2921 bytes: &[u8],
2922 bytes_written: &mut usize,
2923) -> io::Result<()> {
2924 writer.write_all(bytes)?;
2925 *bytes_written = bytes_written.saturating_add(bytes.len());
2926 Ok(())
2927}
2928
2929struct CountingReader<R> {
2930 inner: R,
2931 bytes_read: usize,
2932}
2933
2934impl<R> CountingReader<R> {
2935 fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
2936 Self { inner, bytes_read }
2937 }
2938
2939 fn bytes_read(&self) -> usize {
2940 self.bytes_read
2941 }
2942}
2943
2944impl<R: Read> Read for CountingReader<R> {
2945 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2946 let read = self.inner.read(buf)?;
2947 self.bytes_read = self.bytes_read.saturating_add(read);
2948 Ok(read)
2949 }
2950}
2951
2952fn read_exact_stream<R: Read>(
2953 reader: &mut CountingReader<R>,
2954 buf: &mut [u8],
2955 eof_message: &'static str,
2956) -> Result<(), String> {
2957 reader.read_exact(buf).map_err(|error| {
2958 if error.kind() == io::ErrorKind::UnexpectedEof {
2959 eof_message.to_string()
2960 } else {
2961 format!("{eof_message}: {error}")
2962 }
2963 })
2964}
2965
2966fn read_u8_stream<R: Read>(
2967 reader: &mut CountingReader<R>,
2968 eof_message: &'static str,
2969) -> Result<u8, String> {
2970 let mut bytes = [0u8; 1];
2971 read_exact_stream(reader, &mut bytes, eof_message)?;
2972 Ok(bytes[0])
2973}
2974
2975fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
2976 let mut bytes = [0u8; 4];
2977 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
2978 Ok(u32::from_le_bytes(bytes))
2979}
2980
2981fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
2982 let mut bytes = [0u8; 8];
2983 read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
2984 Ok(u64::from_le_bytes(bytes))
2985}
2986
2987fn read_string_stream<R: Read>(
2988 reader: &mut CountingReader<R>,
2989 total_len: Option<usize>,
2990) -> Result<String, String> {
2991 let len = read_u32_stream(reader)? as usize;
2992 if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
2993 return Err("unexpected end of data reading string".to_string());
2994 }
2995 let mut bytes = vec![0u8; len];
2996 read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
2997 Ok(String::from_utf8_lossy(&bytes).to_string())
2998}
2999
3000struct SourceLineCache<'a> {
3001 lines: Vec<&'a str>,
3002 line_starts: Vec<usize>,
3003}
3004
3005impl<'a> SourceLineCache<'a> {
3006 fn new(source: &'a str) -> Self {
3007 let lines: Vec<&'a str> = source.lines().collect();
3008 let mut line_starts = Vec::with_capacity(lines.len());
3009 let bytes = source.as_bytes();
3010 let mut offset = 0usize;
3011 for line in &lines {
3012 line_starts.push(offset);
3013 offset += line.len();
3014 if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
3015 offset += 2;
3016 } else if bytes.get(offset) == Some(&b'\n') {
3017 offset += 1;
3018 }
3019 }
3020 Self { lines, line_starts }
3021 }
3022
3023 fn len(&self) -> usize {
3024 debug_assert_eq!(self.lines.len(), self.line_starts.len());
3025 self.line_starts.len()
3026 }
3027}
3028
3029fn build_embed_text_with_lines(
3031 symbol: &Symbol,
3032 line_cache: &SourceLineCache<'_>,
3033 file: &Path,
3034 project_root: &Path,
3035) -> String {
3036 let relative = file
3037 .strip_prefix(project_root)
3038 .unwrap_or(file)
3039 .to_string_lossy();
3040
3041 let kind_label = match &symbol.kind {
3042 SymbolKind::Function => "function",
3043 SymbolKind::Class => "class",
3044 SymbolKind::Method => "method",
3045 SymbolKind::Struct => "struct",
3046 SymbolKind::Interface => "interface",
3047 SymbolKind::Enum => "enum",
3048 SymbolKind::TypeAlias => "type",
3049 SymbolKind::Variable => "variable",
3050 SymbolKind::Heading => "heading",
3051 SymbolKind::FileSummary => "file-summary",
3052 };
3053
3054 let name = &symbol.name;
3056 let mut text = format!(
3057 "name:{name} file:{} kind:{} name:{name}",
3058 relative, kind_label
3059 );
3060
3061 if let Some(sig) = &symbol.signature {
3062 text.push_str(&format!(" signature:{}", truncate_chars(sig, 400)));
3070 }
3071
3072 let start = (symbol.range.start_line as usize).min(line_cache.len());
3074 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
3076 if start < end {
3077 let body: String = line_cache.lines[start..end]
3078 .iter()
3079 .take(15) .copied()
3081 .collect::<Vec<&str>>()
3082 .join("\n");
3083 let snippet = if body.len() > 300 {
3084 format!("{}...", &body[..body.floor_char_boundary(300)])
3085 } else {
3086 body
3087 };
3088 text.push_str(&format!(" body:{}", snippet));
3089 }
3090
3091 truncate_chars(&text, MAX_EMBED_TEXT_CHARS)
3096}
3097
3098#[cfg(test)]
3099fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
3100 let line_cache = SourceLineCache::new(source);
3101 build_embed_text_with_lines(symbol, &line_cache, file, project_root)
3102}
3103
3104const MAX_EMBED_TEXT_CHARS: usize = 1600;
3108
3109fn truncate_chars(value: &str, max_chars: usize) -> String {
3110 value.chars().take(max_chars).collect()
3111}
3112
3113fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
3114 let Some((start, first)) = line_cache
3115 .lines
3116 .iter()
3117 .enumerate()
3118 .find(|(_, line)| !line.trim().is_empty())
3119 else {
3120 return String::new();
3121 };
3122
3123 let trimmed = first.trim_start();
3124 if trimmed.starts_with("/**") {
3125 let mut comment = Vec::new();
3126 for line in line_cache.lines.iter().skip(start) {
3127 comment.push(*line);
3128 if line.contains("*/") {
3129 break;
3130 }
3131 }
3132 return truncate_chars(&comment.join("\n"), 200);
3133 }
3134
3135 if trimmed.starts_with("///") || trimmed.starts_with("//!") {
3136 let comment = line_cache
3137 .lines
3138 .iter()
3139 .skip(start)
3140 .take_while(|line| {
3141 let trimmed = line.trim_start();
3142 trimmed.starts_with("///") || trimmed.starts_with("//!")
3143 })
3144 .copied()
3145 .collect::<Vec<_>>()
3146 .join("\n");
3147 return truncate_chars(&comment, 200);
3148 }
3149
3150 String::new()
3151}
3152
3153pub fn build_file_summary_chunk(
3154 file: &Path,
3155 project_root: &Path,
3156 source: &str,
3157 top_exports: &[&str],
3158 top_export_signatures: &[Option<&str>],
3159) -> SemanticChunk {
3160 let line_cache = SourceLineCache::new(source);
3161 build_file_summary_chunk_with_lines(
3162 file,
3163 project_root,
3164 &line_cache,
3165 top_exports,
3166 top_export_signatures,
3167 )
3168}
3169
3170fn build_file_summary_chunk_with_lines(
3171 file: &Path,
3172 project_root: &Path,
3173 line_cache: &SourceLineCache<'_>,
3174 top_exports: &[&str],
3175 top_export_signatures: &[Option<&str>],
3176) -> SemanticChunk {
3177 let relative = file.strip_prefix(project_root).unwrap_or(file);
3178 let rel_path = relative.to_string_lossy();
3179 let parent_dir = relative
3180 .parent()
3181 .map(|parent| parent.to_string_lossy().to_string())
3182 .unwrap_or_default();
3183 let name = file
3184 .file_stem()
3185 .map(|stem| stem.to_string_lossy().to_string())
3186 .unwrap_or_default();
3187 let doc = first_leading_doc_comment(line_cache);
3188 let exports = top_exports
3189 .iter()
3190 .take(5)
3191 .copied()
3192 .collect::<Vec<_>>()
3193 .join(",");
3194 let snippet = if doc.is_empty() {
3195 top_export_signatures
3196 .first()
3197 .and_then(|signature| signature.as_deref())
3198 .map(|signature| truncate_chars(signature, 200))
3199 .unwrap_or_default()
3200 } else {
3201 doc.clone()
3202 };
3203
3204 SemanticChunk {
3205 file: file.to_path_buf(),
3206 name,
3207 qualified_name: None,
3208 kind: SymbolKind::FileSummary,
3209 start_line: 0,
3210 end_line: 0,
3211 exported: false,
3212 embed_text: truncate_chars(
3213 &format!(
3214 "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
3215 file.file_stem()
3216 .map(|stem| stem.to_string_lossy().to_string())
3217 .unwrap_or_default()
3218 ),
3219 MAX_EMBED_TEXT_CHARS,
3220 ),
3221 snippet,
3222 }
3223}
3224
3225fn parser_for(
3226 parsers: &mut HashMap<crate::parser::LangId, Parser>,
3227 lang: crate::parser::LangId,
3228) -> Result<&mut Parser, String> {
3229 use std::collections::hash_map::Entry;
3230
3231 match parsers.entry(lang) {
3232 Entry::Occupied(entry) => Ok(entry.into_mut()),
3233 Entry::Vacant(entry) => {
3234 let grammar = grammar_for(lang);
3235 let mut parser = Parser::new();
3236 parser
3237 .set_language(&grammar)
3238 .map_err(|error| error.to_string())?;
3239 Ok(entry.insert(parser))
3240 }
3241 }
3242}
3243
3244pub fn is_semantic_indexed_extension(path: &Path) -> bool {
3245 matches!(
3246 path.extension().and_then(|extension| extension.to_str()),
3247 Some(
3248 "ts" | "tsx"
3249 | "js"
3250 | "jsx"
3251 | "py"
3252 | "rs"
3253 | "go"
3254 | "c"
3255 | "h"
3256 | "cc"
3257 | "cpp"
3258 | "cxx"
3259 | "hpp"
3260 | "hh"
3261 | "zig"
3262 | "cs"
3263 | "sh"
3264 | "bash"
3265 | "zsh"
3266 | "inc"
3267 | "php"
3268 | "sol"
3269 | "scss"
3270 | "vue"
3271 | "yaml"
3272 | "yml"
3273 | "pas"
3274 | "pp"
3275 | "dpr"
3276 | "dpk"
3277 | "lpr"
3278 | "java"
3279 | "kt"
3280 | "kts"
3281 | "rb"
3282 | "swift"
3283 | "scala"
3284 | "sc"
3285 | "lua"
3286 | "pl"
3287 | "pm"
3288 | "t"
3289 | "r"
3290 | "R"
3291 | "m"
3292 | "mm",
3293 )
3294 )
3295}
3296
3297fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
3298 if let Ok(canonical) = fs::canonicalize(path) {
3299 return canonical;
3300 }
3301
3302 let Some(parent) = path.parent() else {
3303 return path.to_path_buf();
3304 };
3305 let Some(file_name) = path.file_name() else {
3306 return path.to_path_buf();
3307 };
3308
3309 fs::canonicalize(parent)
3310 .map(|canonical_parent| canonical_parent.join(file_name))
3311 .unwrap_or_else(|_| path.to_path_buf())
3312}
3313
3314const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
3324
3325fn collect_semantic_file(
3326 project_root: &Path,
3327 file: &Path,
3328 parsers: &mut HashMap<crate::parser::LangId, Parser>,
3329) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
3330 let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
3331 if !metadata.is_file() {
3332 return Err("not a regular file".to_string());
3333 }
3334 let mtime = metadata.modified().map_err(|error| error.to_string())?;
3335 let size = metadata.len();
3336
3337 if !is_semantic_indexed_extension(file) {
3338 return Err("unsupported file extension".to_string());
3339 }
3340 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
3341
3342 let mut indexed_metadata = IndexedFileMetadata {
3343 mtime,
3344 size,
3345 content_hash: cache_freshness::zero_hash(),
3346 };
3347
3348 if size > MAX_SEMANTIC_FILE_BYTES {
3351 return Ok((indexed_metadata, Vec::new()));
3352 }
3353
3354 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
3355 indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
3356 cache_freshness::hash_bytes(source.as_bytes())
3357 } else {
3358 cache_freshness::zero_hash()
3359 };
3360
3361 let chunks = collect_file_chunks_from_source(project_root, file, lang, parsers, &source)?;
3362 Ok((indexed_metadata, chunks))
3363}
3364
3365#[cfg(test)]
3366fn collect_file_chunks(
3367 project_root: &Path,
3368 file: &Path,
3369 parsers: &mut HashMap<crate::parser::LangId, Parser>,
3370) -> Result<Vec<SemanticChunk>, String> {
3371 if !is_semantic_indexed_extension(file) {
3372 return Err("unsupported file extension".to_string());
3373 }
3374 let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
3375 if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
3378 return Ok(Vec::new());
3379 }
3380 let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
3381 collect_file_chunks_from_source(project_root, file, lang, parsers, &source)
3382}
3383
3384fn collect_file_chunks_from_source(
3385 project_root: &Path,
3386 file: &Path,
3387 lang: crate::parser::LangId,
3388 parsers: &mut HashMap<crate::parser::LangId, Parser>,
3389 source: &str,
3390) -> Result<Vec<SemanticChunk>, String> {
3391 let tree = parser_for(parsers, lang)?
3392 .parse(source, None)
3393 .ok_or_else(|| format!("tree-sitter parse returned None for {}", file.display()))?;
3394 let symbols =
3395 extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string())?;
3396
3397 Ok(symbols_to_chunks(file, &symbols, source, project_root))
3398}
3399
3400fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
3402 let start = (symbol.range.start_line as usize).min(line_cache.len());
3403 let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
3405 if start < end {
3406 let snippet_lines: Vec<&str> = line_cache.lines[start..end]
3407 .iter()
3408 .take(5)
3409 .copied()
3410 .collect();
3411 let mut snippet = snippet_lines.join("\n");
3412 if end - start > 5 {
3413 snippet.push_str("\n ...");
3414 }
3415 if snippet.len() > 300 {
3416 snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
3417 }
3418 snippet
3419 } else {
3420 String::new()
3421 }
3422}
3423
3424#[cfg(test)]
3425fn build_snippet(symbol: &Symbol, source: &str) -> String {
3426 let line_cache = SourceLineCache::new(source);
3427 build_snippet_with_lines(symbol, &line_cache)
3428}
3429
3430fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
3431 let mut parts = symbol
3432 .scope_chain
3433 .iter()
3434 .filter(|part| !part.is_empty())
3435 .cloned()
3436 .collect::<Vec<_>>();
3437 if !symbol.name.is_empty() {
3438 parts.push(symbol.name.clone());
3439 }
3440 (!parts.is_empty()).then(|| parts.join("."))
3441}
3442
3443fn symbols_to_chunks(
3445 file: &Path,
3446 symbols: &[Symbol],
3447 source: &str,
3448 project_root: &Path,
3449) -> Vec<SemanticChunk> {
3450 let line_cache = SourceLineCache::new(source);
3451 let mut chunks = Vec::new();
3452 let top_exports_with_signatures = symbols
3453 .iter()
3454 .filter(|symbol| {
3455 symbol.exported
3456 && symbol.parent.is_none()
3457 && !matches!(symbol.kind, SymbolKind::Heading)
3458 })
3459 .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
3460 .collect::<Vec<_>>();
3461
3462 let has_only_headings = !symbols.is_empty()
3463 && symbols
3464 .iter()
3465 .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
3466 if top_exports_with_signatures.len() <= 2 && !has_only_headings {
3467 let top_exports = top_exports_with_signatures
3468 .iter()
3469 .map(|(name, _)| *name)
3470 .collect::<Vec<_>>();
3471 let top_export_signatures = top_exports_with_signatures
3472 .iter()
3473 .map(|(_, signature)| *signature)
3474 .collect::<Vec<_>>();
3475 chunks.push(build_file_summary_chunk_with_lines(
3476 file,
3477 project_root,
3478 &line_cache,
3479 &top_exports,
3480 &top_export_signatures,
3481 ));
3482 }
3483
3484 for symbol in symbols {
3485 if matches!(symbol.kind, SymbolKind::Heading) {
3490 continue;
3491 }
3492
3493 let line_count = symbol
3495 .range
3496 .end_line
3497 .saturating_sub(symbol.range.start_line)
3498 + 1;
3499 if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
3500 continue;
3501 }
3502
3503 let embed_text = build_embed_text_with_lines(symbol, &line_cache, file, project_root);
3504 let snippet = build_snippet_with_lines(symbol, &line_cache);
3505
3506 chunks.push(SemanticChunk {
3507 file: file.to_path_buf(),
3508 name: symbol.name.clone(),
3509 qualified_name: qualified_name_for_symbol(symbol),
3510 kind: symbol.kind.clone(),
3511 start_line: symbol.range.start_line,
3512 end_line: symbol.range.end_line,
3513 exported: symbol.exported,
3514 embed_text,
3515 snippet,
3516 });
3517
3518 }
3521
3522 chunks
3523}
3524
3525fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
3526 b.0.partial_cmp(&a.0)
3527 .unwrap_or(std::cmp::Ordering::Equal)
3528 .then_with(|| a.1.cmp(&b.1))
3529}
3530
3531fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
3533 if a.len() != b.len() {
3534 return 0.0;
3535 }
3536
3537 let mut dot = 0.0f32;
3538 let mut norm_a = 0.0f32;
3539 let mut norm_b = 0.0f32;
3540
3541 for i in 0..a.len() {
3542 dot += a[i] * b[i];
3543 norm_a += a[i] * a[i];
3544 norm_b += b[i] * b[i];
3545 }
3546
3547 let denom = norm_a.sqrt() * norm_b.sqrt();
3548 if denom == 0.0 {
3549 0.0
3550 } else {
3551 dot / denom
3552 }
3553}
3554
3555fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
3557 match kind {
3558 SymbolKind::Function => 0,
3559 SymbolKind::Class => 1,
3560 SymbolKind::Method => 2,
3561 SymbolKind::Struct => 3,
3562 SymbolKind::Interface => 4,
3563 SymbolKind::Enum => 5,
3564 SymbolKind::TypeAlias => 6,
3565 SymbolKind::Variable => 7,
3566 SymbolKind::Heading => 8,
3567 SymbolKind::FileSummary => 9,
3568 }
3569}
3570
3571fn u8_to_symbol_kind(v: u8) -> SymbolKind {
3572 match v {
3573 0 => SymbolKind::Function,
3574 1 => SymbolKind::Class,
3575 2 => SymbolKind::Method,
3576 3 => SymbolKind::Struct,
3577 4 => SymbolKind::Interface,
3578 5 => SymbolKind::Enum,
3579 6 => SymbolKind::TypeAlias,
3580 7 => SymbolKind::Variable,
3581 8 => SymbolKind::Heading,
3582 9 => SymbolKind::FileSummary,
3583 _ => SymbolKind::Heading,
3584 }
3585}
3586
3587#[cfg(test)]
3588mod tests {
3589 use super::*;
3590 use crate::config::{SemanticBackend, SemanticBackendConfig};
3591 use crate::parser::FileParser;
3592 use std::io::{Read, Write};
3593 use std::net::TcpListener;
3594 use std::thread;
3595
3596 #[test]
3597 fn semantic_index_includes_php_inc_and_scss_extensions() {
3598 for file in ["partial.inc", "index.php", "styles.scss"] {
3599 assert!(
3600 is_semantic_indexed_extension(Path::new(file)),
3601 "{file} should be semantic-index eligible"
3602 );
3603 }
3604 }
3605
3606 #[test]
3607 fn transient_marker_round_trips_and_classifies() {
3608 let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
3611 assert!(embedding_failure_is_transient(&marked));
3612 let clean = strip_transient_embedding_marker(&marked);
3613 assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
3614 assert!(clean.starts_with("openai compatible request failed:"));
3615
3616 for permanent in [
3619 "openai compatible request failed (HTTP 401): Unauthorized",
3620 "embedding dimension mismatch: index has 384, model returned 768",
3621 "too many files (>20000) for semantic indexing (max 20000)",
3622 ] {
3623 assert!(
3624 !embedding_failure_is_transient(permanent),
3625 "{permanent:?} must not be transient"
3626 );
3627 assert_eq!(strip_transient_embedding_marker(permanent), permanent);
3629 }
3630 }
3631
3632 #[test]
3633 fn send_error_transience_separates_connect_timeout_from_4xx() {
3634 assert!(is_retryable_embedding_status(
3636 reqwest::StatusCode::INTERNAL_SERVER_ERROR
3637 ));
3638 assert!(is_retryable_embedding_status(
3639 reqwest::StatusCode::TOO_MANY_REQUESTS
3640 ));
3641 assert!(!is_retryable_embedding_status(
3642 reqwest::StatusCode::UNAUTHORIZED
3643 ));
3644 assert!(!is_retryable_embedding_status(
3645 reqwest::StatusCode::BAD_REQUEST
3646 ));
3647 }
3648
3649 #[test]
3650 fn local_backend_model_loading_body_is_transient() {
3651 for body in [
3654 r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
3655 r#"{"error":"model is loading, please wait"}"#,
3656 r#"{"error":"Model not loaded"}"#,
3657 "Loading model into memory",
3658 ] {
3659 assert!(
3660 embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
3661 "{body:?} should be body-transient"
3662 );
3663 }
3664
3665 for body in [
3669 r#"{"error":"invalid api key"}"#,
3670 r#"{"error":"model 'foo' not found"}"#,
3671 "Bad Request: unknown field",
3672 "Bad Request: invalid loading model option",
3673 r#"{"error":"unauthorized while model is being loaded by another account"}"#,
3674 ] {
3675 assert!(
3676 !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
3677 "{body:?} must not be body-transient"
3678 );
3679 }
3680
3681 assert!(
3682 !embedding_response_body_is_transient(
3683 reqwest::StatusCode::UNAUTHORIZED,
3684 r#"{"error":"model is loading, please wait"}"#
3685 ),
3686 "permanent auth failures must not become transient because of body text"
3687 );
3688 }
3689
3690 fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
3691 where
3692 F: Fn(String, String, String) -> String + Send + 'static,
3693 {
3694 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
3695 let addr = listener.local_addr().expect("local addr");
3696 let handle = thread::spawn(move || {
3697 let (mut stream, _) = listener.accept().expect("accept request");
3698 let mut buf = Vec::new();
3699 let mut chunk = [0u8; 4096];
3700 let mut header_end = None;
3701 let mut content_length = 0usize;
3702 loop {
3703 let n = stream.read(&mut chunk).expect("read request");
3704 if n == 0 {
3705 break;
3706 }
3707 buf.extend_from_slice(&chunk[..n]);
3708 if header_end.is_none() {
3709 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
3710 header_end = Some(pos + 4);
3711 let headers = String::from_utf8_lossy(&buf[..pos + 4]);
3712 for line in headers.lines() {
3713 if let Some(value) = line.strip_prefix("Content-Length:") {
3714 content_length = value.trim().parse::<usize>().unwrap_or(0);
3715 }
3716 }
3717 }
3718 }
3719 if let Some(end) = header_end {
3720 if buf.len() >= end + content_length {
3721 break;
3722 }
3723 }
3724 }
3725
3726 let end = header_end.expect("header terminator");
3727 let request = String::from_utf8_lossy(&buf[..end]).to_string();
3728 let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
3729 let mut lines = request.lines();
3730 let request_line = lines.next().expect("request line").to_string();
3731 let path = request_line
3732 .split_whitespace()
3733 .nth(1)
3734 .expect("request path")
3735 .to_string();
3736 let response_body = handler(request_line, path, body);
3737 let response = format!(
3738 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3739 response_body.len(),
3740 response_body
3741 );
3742 stream
3743 .write_all(response.as_bytes())
3744 .expect("write response");
3745 });
3746
3747 (format!("http://{}", addr), handle)
3748 }
3749
3750 fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
3751 let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
3752 listener
3753 .set_nonblocking(true)
3754 .expect("nonblocking listener");
3755 let addr = listener.local_addr().expect("local addr");
3756 let handle = thread::spawn(move || {
3757 let deadline = std::time::Instant::now() + Duration::from_secs(30);
3767 let mut accepted = 0usize;
3768 while accepted < attempts && std::time::Instant::now() < deadline {
3769 match listener.accept() {
3770 Ok((mut stream, _)) => {
3771 accepted += 1;
3772 let mut buf = [0u8; 4096];
3773 let _ = stream.read(&mut buf);
3781 let response = "HTTP/1.1 200 OK
3782Content-Type: application/json
3783Content-Length: 128
3784Connection: close
3785
3786{";
3787 let _ = stream.write_all(response.as_bytes());
3788 }
3789 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
3790 thread::sleep(Duration::from_millis(10));
3791 }
3792 Err(error) => panic!("accept request: {error}"),
3793 }
3794 }
3795 });
3796
3797 (format!("http://{}", addr), handle)
3798 }
3799
3800 #[test]
3801 fn response_body_read_failures_are_marked_transient() {
3802 let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
3803 let client = Client::builder()
3804 .timeout(Duration::from_millis(250))
3805 .build()
3806 .expect("client");
3807
3808 let error = send_embedding_request(|| client.post(&url).body("{}"), "test backend")
3809 .expect_err("truncated body should fail");
3810
3811 handle.join().unwrap();
3812 assert!(
3813 embedding_failure_is_transient(&error),
3814 "body read failures should be transient-marked: {error}"
3815 );
3816 assert!(error.contains("response read failed"));
3817 }
3818
3819 fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
3820 Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
3821 }
3822
3823 fn write_rust_file(path: &Path, function_name: &str) {
3824 fs::write(
3825 path,
3826 format!("pub fn {function_name}() -> bool {{\n true\n}}\n"),
3827 )
3828 .unwrap();
3829 }
3830
3831 fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
3832 let mut embed = test_vector_for_texts;
3833 SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
3834 }
3835
3836 fn test_project_root() -> PathBuf {
3837 std::env::current_dir().unwrap()
3838 }
3839
3840 fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
3841 index.file_mtimes.insert(file.to_path_buf(), mtime);
3842 index.file_sizes.insert(file.to_path_buf(), size);
3843 index
3844 .file_hashes
3845 .insert(file.to_path_buf(), cache_freshness::zero_hash());
3846 }
3847
3848 fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
3849 let mut buf = Vec::new();
3850 let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
3851 let encoded = fingerprint.as_string();
3852 if encoded.is_empty() {
3853 None
3854 } else {
3855 Some(encoded.into_bytes())
3856 }
3857 });
3858 let file_mtimes: Vec<_> = index
3859 .file_mtimes
3860 .iter()
3861 .filter_map(|(path, mtime)| {
3862 cache_relative_path(&index.project_root, path)
3863 .map(|relative| (relative, path, mtime))
3864 })
3865 .collect();
3866 let entries: Vec<_> = index
3867 .entries
3868 .iter()
3869 .filter_map(|entry| {
3870 cache_relative_path(&index.project_root, &entry.chunk.file)
3871 .map(|relative| (relative, entry))
3872 })
3873 .collect();
3874
3875 buf.push(SEMANTIC_INDEX_VERSION_V6);
3876 buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
3877 buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
3878 let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
3879 buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
3880 buf.extend_from_slice(fp_bytes_ref);
3881
3882 buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
3883 for (relative, path, mtime) in &file_mtimes {
3884 let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
3885 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
3886 buf.extend_from_slice(&path_bytes);
3887 let duration = mtime
3888 .duration_since(SystemTime::UNIX_EPOCH)
3889 .unwrap_or_default();
3890 buf.extend_from_slice(&duration.as_secs().to_le_bytes());
3891 buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
3892 let size = index.file_sizes.get(*path).copied().unwrap_or_default();
3893 buf.extend_from_slice(&size.to_le_bytes());
3894 let hash = index
3895 .file_hashes
3896 .get(*path)
3897 .copied()
3898 .unwrap_or_else(cache_freshness::zero_hash);
3899 buf.extend_from_slice(hash.as_bytes());
3900 }
3901
3902 for (relative, entry) in &entries {
3903 let c = &entry.chunk;
3904 let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
3905 buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
3906 buf.extend_from_slice(&file_bytes);
3907
3908 let name_bytes = c.name.as_bytes();
3909 buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
3910 buf.extend_from_slice(name_bytes);
3911
3912 buf.push(symbol_kind_to_u8(&c.kind));
3913 buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
3914 buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
3915 buf.push(c.exported as u8);
3916
3917 let snippet_bytes = c.snippet.as_bytes();
3918 buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
3919 buf.extend_from_slice(snippet_bytes);
3920
3921 let embed_bytes = c.embed_text.as_bytes();
3922 buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
3923 buf.extend_from_slice(embed_bytes);
3924
3925 for &val in &entry.vector {
3926 buf.extend_from_slice(&val.to_le_bytes());
3927 }
3928 }
3929
3930 buf
3931 }
3932
3933 #[derive(Default)]
3934 struct RecordingEmbedder {
3935 calls: Vec<Vec<String>>,
3936 }
3937
3938 impl RecordingEmbedder {
3939 fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
3940 let vectors = texts
3941 .iter()
3942 .map(|text| deterministic_test_vector(text))
3943 .collect();
3944 self.calls.push(texts);
3945 Ok(vectors)
3946 }
3947
3948 fn total_embedded_texts(&self) -> usize {
3949 self.calls.iter().map(Vec::len).sum()
3950 }
3951
3952 fn embedded_texts(&self) -> Vec<&str> {
3953 self.calls
3954 .iter()
3955 .flat_map(|batch| batch.iter().map(String::as_str))
3956 .collect()
3957 }
3958 }
3959
3960 fn deterministic_test_vector(text: &str) -> Vec<f32> {
3961 let hash = blake3::hash(text.as_bytes());
3962 let bytes = hash.as_bytes();
3963 vec![
3964 1.0,
3965 bytes[0] as f32 / 255.0,
3966 bytes[1] as f32 / 255.0,
3967 bytes[2] as f32 / 255.0,
3968 ]
3969 }
3970
3971 fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
3972 let mut embedder = RecordingEmbedder::default();
3973 let mut embed = |texts: Vec<String>| embedder.embed(texts);
3974 SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
3975 }
3976
3977 fn force_stale(index: &mut SemanticIndex, file: &Path) {
3978 set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
3979 }
3980
3981 fn write_source(path: &Path, source: &str) {
3982 if let Some(parent) = path.parent() {
3983 fs::create_dir_all(parent).unwrap();
3984 }
3985 fs::write(path, source).unwrap();
3986 }
3987
3988 fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
3989 index
3990 .entries
3991 .iter()
3992 .filter(|entry| entry.chunk.file == file)
3993 .collect()
3994 }
3995
3996 fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
3997 index
3998 .entries
3999 .iter()
4000 .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
4001 .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
4002 }
4003
4004 fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
4005 index
4006 .entries
4007 .iter()
4008 .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
4009 .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
4010 }
4011
4012 #[test]
4013 fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
4014 let temp = tempfile::tempdir().unwrap();
4015 let project_root = temp.path();
4016 let file = project_root.join("src/lib.rs");
4017 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
4018 write_source(&file, original);
4019
4020 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4021 let original_entry_count = index.entries.len();
4022 let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
4023
4024 write_source(&file, &format!("\n{original}"));
4025 force_stale(&mut index, &file);
4026
4027 let mut embedder = RecordingEmbedder::default();
4028 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4029 let mut progress = |_done: usize, _total: usize| {};
4030 let summary = index
4031 .refresh_stale_files(
4032 project_root,
4033 std::slice::from_ref(&file),
4034 &mut embed,
4035 16,
4036 &mut progress,
4037 )
4038 .unwrap();
4039
4040 assert_eq!(summary.changed, 1);
4041 assert_eq!(embedder.total_embedded_texts(), 0);
4042 assert_eq!(index.entries.len(), original_entry_count);
4043 let shifted_alpha = entry_by_name(&index, &file, "alpha");
4044 assert_eq!(shifted_alpha.chunk.start_line, 1);
4045 assert_eq!(shifted_alpha.vector, original_alpha_vector);
4046 }
4047
4048 #[test]
4049 fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
4050 let temp = tempfile::tempdir().unwrap();
4051 let project_root = temp.path();
4052 let file = project_root.join("src/lib.rs");
4053 let original = "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n";
4054 write_source(&file, original);
4055
4056 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4057 let mut serving_index = worker_index.clone();
4058 let original_entry_count = worker_index.entries.len();
4059
4060 write_source(&file, &format!("\n{original}"));
4061
4062 let mut embedder = RecordingEmbedder::default();
4063 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4064 let mut progress = |_done: usize, _total: usize| {};
4065 let update = worker_index
4066 .refresh_invalidated_files(
4067 project_root,
4068 std::slice::from_ref(&file),
4069 &mut embed,
4070 16,
4071 100,
4072 &mut progress,
4073 )
4074 .unwrap();
4075
4076 assert_eq!(embedder.total_embedded_texts(), 0);
4077 assert_eq!(update.added_entries.len(), original_entry_count);
4078 assert_eq!(worker_index.entries.len(), original_entry_count);
4079
4080 serving_index.apply_refresh_update(
4081 update.added_entries,
4082 update.updated_metadata,
4083 &update.completed_paths,
4084 );
4085
4086 assert_eq!(serving_index.entries.len(), original_entry_count);
4087 assert_eq!(
4088 entries_for_file(&serving_index, &file).len(),
4089 original_entry_count
4090 );
4091 assert_eq!(
4092 entry_by_name(&serving_index, &file, "alpha")
4093 .chunk
4094 .start_line,
4095 1
4096 );
4097 }
4098
4099 #[test]
4100 fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
4101 let temp = tempfile::tempdir().unwrap();
4102 let project_root = temp.path();
4103 let file = project_root.join("src/lib.rs");
4104 write_source(
4105 &file,
4106 "pub fn alpha() -> i32 {\n 1\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
4107 );
4108
4109 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4110 let original_entry_count = index.entries.len();
4111 let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
4112
4113 write_source(
4114 &file,
4115 "pub fn alpha() -> i32 {\n 10\n}\n\npub fn beta() -> i32 {\n 2\n}\n",
4116 );
4117
4118 let mut embedder = RecordingEmbedder::default();
4119 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4120 let mut progress = |_done: usize, _total: usize| {};
4121 let update = index
4122 .refresh_invalidated_files(
4123 project_root,
4124 std::slice::from_ref(&file),
4125 &mut embed,
4126 16,
4127 100,
4128 &mut progress,
4129 )
4130 .unwrap();
4131
4132 assert_eq!(embedder.total_embedded_texts(), 1);
4133 assert!(embedder.embedded_texts()[0].contains("name:alpha"));
4134 assert_eq!(update.added_entries.len(), original_entry_count);
4135 assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
4136 }
4137
4138 #[test]
4139 fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
4140 let temp = tempfile::tempdir().unwrap();
4141 let project_root = temp.path();
4142 let file = project_root.join("src/dupe.js");
4143 let one_duplicate = "function duplicate() {\n return 1;\n}\n";
4144 write_source(&file, one_duplicate);
4145
4146 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4147 let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
4148
4149 write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
4150
4151 let mut embedder = RecordingEmbedder::default();
4152 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4153 let mut progress = |_done: usize, _total: usize| {};
4154 index
4155 .refresh_invalidated_files(
4156 project_root,
4157 std::slice::from_ref(&file),
4158 &mut embed,
4159 16,
4160 100,
4161 &mut progress,
4162 )
4163 .unwrap();
4164
4165 let duplicate_entries = index
4166 .entries
4167 .iter()
4168 .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
4169 .collect::<Vec<_>>();
4170 assert_eq!(duplicate_entries.len(), 2);
4171 assert_eq!(embedder.total_embedded_texts(), 0);
4172 assert_eq!(duplicate_entries[0].vector, original_vector);
4173 assert_eq!(duplicate_entries[1].vector, original_vector);
4174 }
4175
4176 #[test]
4177 fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
4178 let temp = tempfile::tempdir().unwrap();
4179 let project_root = temp.path();
4180 let file = project_root.join("src/lib.rs");
4181 write_source(
4182 &file,
4183 "//! module docs v1\n\npub fn alpha() -> i32 {\n 1\n}\n",
4184 );
4185
4186 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4187 let summary_before = file_summary_entry(&index, &file).vector.clone();
4188
4189 write_source(
4190 &file,
4191 "//! module docs v1\n\npub fn alpha() -> i32 {\n 2\n}\n",
4192 );
4193 let mut body_embedder = RecordingEmbedder::default();
4194 let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
4195 let mut progress = |_done: usize, _total: usize| {};
4196 index
4197 .refresh_invalidated_files(
4198 project_root,
4199 std::slice::from_ref(&file),
4200 &mut body_embed,
4201 16,
4202 100,
4203 &mut progress,
4204 )
4205 .unwrap();
4206 assert_eq!(body_embedder.total_embedded_texts(), 1);
4207 assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
4208 assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
4209
4210 write_source(
4211 &file,
4212 "//! module docs v2\n\npub fn alpha() -> i32 {\n 2\n}\n",
4213 );
4214 let mut doc_embedder = RecordingEmbedder::default();
4215 let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
4216 index
4217 .refresh_invalidated_files(
4218 project_root,
4219 std::slice::from_ref(&file),
4220 &mut doc_embed,
4221 16,
4222 100,
4223 &mut progress,
4224 )
4225 .unwrap();
4226
4227 assert_eq!(doc_embedder.total_embedded_texts(), 1);
4228 assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
4229 assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
4230 }
4231
4232 #[test]
4233 fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
4234 let temp = tempfile::tempdir().unwrap();
4235 let project_root = temp.path();
4236 let file = project_root.join("src/lib.rs");
4237 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
4238
4239 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4240 let mut serving_index = worker_index.clone();
4241 fs::remove_file(&file).unwrap();
4242
4243 let mut embedder = RecordingEmbedder::default();
4244 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4245 let mut progress = |_done: usize, _total: usize| {};
4246 let update = worker_index
4247 .refresh_invalidated_files(
4248 project_root,
4249 std::slice::from_ref(&file),
4250 &mut embed,
4251 16,
4252 100,
4253 &mut progress,
4254 )
4255 .unwrap();
4256
4257 assert_eq!(update.summary.deleted, 1);
4258 assert_eq!(embedder.total_embedded_texts(), 0);
4259 assert!(worker_index.entries.is_empty());
4260
4261 serving_index.apply_refresh_update(
4262 update.added_entries,
4263 update.updated_metadata,
4264 &update.completed_paths,
4265 );
4266 assert!(serving_index.entries.is_empty());
4267 }
4268
4269 #[test]
4270 fn watcher_collect_failure_does_not_resurrect_stale_entries() {
4271 let temp = tempfile::tempdir().unwrap();
4272 let project_root = temp.path();
4273 let file = project_root.join("src/lib.rs");
4274 write_source(&file, "pub fn alpha() -> i32 {\n 1\n}\n");
4275
4276 let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
4277 let mut serving_index = worker_index.clone();
4278 fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
4279
4280 let mut embedder = RecordingEmbedder::default();
4281 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4282 let mut progress = |_done: usize, _total: usize| {};
4283 let update = worker_index
4284 .refresh_invalidated_files(
4285 project_root,
4286 std::slice::from_ref(&file),
4287 &mut embed,
4288 16,
4289 100,
4290 &mut progress,
4291 )
4292 .unwrap();
4293
4294 assert_eq!(embedder.total_embedded_texts(), 0);
4295 assert!(update.added_entries.is_empty());
4296 assert!(worker_index.entries.is_empty());
4297 assert!(!worker_index.file_mtimes.contains_key(&file));
4298
4299 serving_index.apply_refresh_update(
4300 update.added_entries,
4301 update.updated_metadata,
4302 &update.completed_paths,
4303 );
4304 assert!(serving_index.entries.is_empty());
4305 assert!(!serving_index.file_mtimes.contains_key(&file));
4306 }
4307
4308 #[test]
4309 fn refresh_invalidated_cap_deferral_remains_file_count_based() {
4310 let temp = tempfile::tempdir().unwrap();
4311 let project_root = temp.path();
4312 let indexed = project_root.join("src/a.rs");
4313 let deferred = project_root.join("src/b.rs");
4314 write_source(&indexed, "pub fn alpha() -> i32 {\n 1\n}\n");
4315 write_source(&deferred, "pub fn beta() -> i32 {\n 2\n}\n");
4316
4317 let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
4318 let mut embedder = RecordingEmbedder::default();
4319 let mut embed = |texts: Vec<String>| embedder.embed(texts);
4320 let mut progress = |_done: usize, _total: usize| {};
4321 let update = index
4322 .refresh_invalidated_files(
4323 project_root,
4324 std::slice::from_ref(&deferred),
4325 &mut embed,
4326 16,
4327 1,
4328 &mut progress,
4329 )
4330 .unwrap();
4331
4332 assert_eq!(update.summary.total_processed, 1);
4333 assert_eq!(update.summary.added, 0);
4334 assert_eq!(embedder.total_embedded_texts(), 0);
4335 assert_eq!(index.indexed_file_count(), 1);
4336 assert!(index.deferred_files.contains(&deferred));
4337 assert!(entries_for_file(&index, &deferred).is_empty());
4338 }
4339
4340 #[test]
4341 fn semantic_cache_serialization_skips_paths_outside_project_root() {
4342 let dir = tempfile::tempdir().expect("create temp dir");
4343 let project = fs::canonicalize(dir.path()).expect("canonical project");
4344 let outside = project.join("..").join("outside.rs");
4345 let mut index = SemanticIndex::new(project.clone(), 3);
4346 index
4347 .file_mtimes
4348 .insert(outside.clone(), SystemTime::UNIX_EPOCH);
4349 index.file_sizes.insert(outside.clone(), 1);
4350 index
4351 .file_hashes
4352 .insert(outside.clone(), cache_freshness::zero_hash());
4353 index.entries.push(EmbeddingEntry {
4354 chunk: SemanticChunk {
4355 file: outside,
4356 name: "outside".to_string(),
4357 qualified_name: None,
4358 kind: SymbolKind::Function,
4359 start_line: 0,
4360 end_line: 0,
4361 exported: false,
4362 embed_text: "outside".to_string(),
4363 snippet: "outside".to_string(),
4364 },
4365 vector: vec![1.0, 0.0, 0.0],
4366 });
4367
4368 let bytes = index.to_bytes();
4369 let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
4370 assert_eq!(loaded.entries.len(), 0);
4371 assert!(loaded.file_mtimes.is_empty());
4372 }
4373
4374 #[test]
4375 fn semantic_search_bounded_top_k_matches_reference_full_sort() {
4376 let project_root = test_project_root();
4377 let file = project_root.join("src/lib.rs");
4378 let mut index = SemanticIndex::new(project_root, 2);
4379 let entries = [
4380 ("alpha", vec![1.0, 0.0], false),
4381 ("beta", vec![0.0, 1.0], false),
4382 ("gamma", vec![1.0, 0.0], false),
4383 ("delta", vec![0.5, 0.5], true),
4384 ("epsilon", vec![-1.0, 0.0], false),
4385 ];
4386 for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
4387 index.entries.push(EmbeddingEntry {
4388 chunk: SemanticChunk {
4389 file: file.clone(),
4390 name: name.to_string(),
4391 qualified_name: None,
4392 kind: SymbolKind::Function,
4393 start_line: line as u32 + 1,
4394 end_line: line as u32 + 1,
4395 exported,
4396 embed_text: name.to_string(),
4397 snippet: format!("fn {name}() {{}}"),
4398 },
4399 vector,
4400 });
4401 }
4402
4403 let query = vec![1.0, 0.0];
4404 let top_k = 4;
4405 let mut reference: Vec<(f32, usize)> = index
4406 .entries
4407 .iter()
4408 .enumerate()
4409 .map(|(idx, entry)| {
4410 let mut score = cosine_similarity(&query, &entry.vector);
4411 if entry.chunk.exported {
4412 score *= 1.1;
4413 }
4414 (score, idx)
4415 })
4416 .collect();
4417 reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
4418 let expected: Vec<(String, f32)> = reference
4419 .into_iter()
4420 .take(top_k)
4421 .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
4422 .collect();
4423
4424 let actual: Vec<(String, f32)> = index
4425 .search(&query, top_k)
4426 .into_iter()
4427 .map(|result| (result.name, result.score))
4428 .collect();
4429
4430 assert_eq!(
4431 actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
4432 expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
4433 );
4434 for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
4435 assert!((actual_score - expected_score).abs() < 1e-6);
4436 }
4437 assert_eq!(actual[0].0, "alpha");
4438 assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
4439 assert!(index.search(&query, 0).is_empty());
4440 }
4441
4442 #[test]
4443 fn test_cosine_similarity_identical() {
4444 let a = vec![1.0, 0.0, 0.0];
4445 let b = vec![1.0, 0.0, 0.0];
4446 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
4447 }
4448
4449 #[test]
4450 fn test_cosine_similarity_orthogonal() {
4451 let a = vec![1.0, 0.0, 0.0];
4452 let b = vec![0.0, 1.0, 0.0];
4453 assert!(cosine_similarity(&a, &b).abs() < 0.001);
4454 }
4455
4456 #[test]
4457 fn test_cosine_similarity_opposite() {
4458 let a = vec![1.0, 0.0, 0.0];
4459 let b = vec![-1.0, 0.0, 0.0];
4460 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
4461 }
4462
4463 #[test]
4464 fn test_serialization_roundtrip() {
4465 let project_root = test_project_root();
4466 let file = project_root.join("src/main.rs");
4467 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
4468 index.entries.push(EmbeddingEntry {
4469 chunk: SemanticChunk {
4470 file: file.clone(),
4471 name: "handle_request".to_string(),
4472 qualified_name: None,
4473 kind: SymbolKind::Function,
4474 start_line: 10,
4475 end_line: 25,
4476 exported: true,
4477 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
4478 snippet: "fn handle_request() {\n // ...\n}".to_string(),
4479 },
4480 vector: vec![0.1, 0.2, 0.3, 0.4],
4481 });
4482 index.dimension = 4;
4483 index
4484 .file_mtimes
4485 .insert(file.clone(), SystemTime::UNIX_EPOCH);
4486 index.file_sizes.insert(file, 0);
4487 index.set_fingerprint(SemanticIndexFingerprint {
4488 backend: "fastembed".to_string(),
4489 model: "all-MiniLM-L6-v2".to_string(),
4490 base_url: FALLBACK_BACKEND.to_string(),
4491 dimension: 4,
4492 chunking_version: default_chunking_version(),
4493 });
4494
4495 let bytes = index.to_bytes();
4496 let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
4497
4498 assert_eq!(restored.entries.len(), 1);
4499 assert_eq!(restored.entries[0].chunk.name, "handle_request");
4500 assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
4501 assert_eq!(restored.dimension, 4);
4502 assert_eq!(restored.backend_label(), Some("fastembed"));
4503 assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
4504 }
4505
4506 #[test]
4507 fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
4508 let storage = tempfile::tempdir().expect("create storage dir");
4509 let project = storage.path().join("project");
4510 fs::create_dir_all(project.join("src")).expect("create project src");
4511 let file = project.join("src/lib.rs");
4512 fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
4513 let project_root = fs::canonicalize(&project).expect("canonical project");
4514 let file = fs::canonicalize(&file).expect("canonical file");
4515
4516 let mut index = SemanticIndex::new(project_root.clone(), 3);
4517 let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
4518 index.file_mtimes.insert(file.clone(), mtime);
4519 index.file_sizes.insert(file.clone(), 42);
4520 index
4521 .file_hashes
4522 .insert(file.clone(), cache_freshness::zero_hash());
4523 index.entries.push(EmbeddingEntry {
4524 chunk: SemanticChunk {
4525 file: file.clone(),
4526 name: "alpha".to_string(),
4527 qualified_name: Some("Service.alpha".to_string()),
4528 kind: SymbolKind::Function,
4529 start_line: 0,
4530 end_line: 0,
4531 exported: true,
4532 embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
4533 snippet: "pub fn alpha() {}".to_string(),
4534 },
4535 vector: vec![0.1, 0.2, 0.3],
4536 });
4537 index.entries.push(EmbeddingEntry {
4538 chunk: SemanticChunk {
4539 file: file.clone(),
4540 name: "beta".to_string(),
4541 qualified_name: Some("Service.beta".to_string()),
4542 kind: SymbolKind::Function,
4543 start_line: 1,
4544 end_line: 1,
4545 exported: true,
4546 embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
4547 snippet: "pub fn beta() {}".to_string(),
4548 },
4549 vector: vec![0.4, 0.5, 0.6],
4550 });
4551 let fingerprint = SemanticIndexFingerprint {
4552 backend: "fastembed".to_string(),
4553 model: "all-MiniLM-L6-v2".to_string(),
4554 base_url: FALLBACK_BACKEND.to_string(),
4555 dimension: 3,
4556 chunking_version: default_chunking_version(),
4557 };
4558 let fingerprint_before = fingerprint.as_string();
4559 index.set_fingerprint(fingerprint.clone());
4560
4561 let legacy_bytes = legacy_semantic_index_bytes(&index);
4562 assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
4563 let legacy_dir = storage.path().join("semantic/legacy-proj");
4564 fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
4565 let legacy_path = legacy_dir.join("semantic.bin");
4566 fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
4567 let legacy_loaded = SemanticIndex::read_from_disk(
4568 storage.path(),
4569 "legacy-proj",
4570 &project_root,
4571 false,
4572 Some(&fingerprint_before),
4573 )
4574 .expect("load v6 semantic index");
4575 assert!(
4576 legacy_path.exists(),
4577 "compatible V6 cache must not be deleted"
4578 );
4579 assert!(legacy_loaded
4580 .entries
4581 .iter()
4582 .all(|entry| entry.chunk.qualified_name.is_none()));
4583 assert_eq!(
4584 legacy_loaded.fingerprint().unwrap().as_string(),
4585 fingerprint_before
4586 );
4587
4588 let v7_bytes = index.to_bytes();
4589 assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
4590 assert_ne!(v7_bytes, legacy_bytes);
4591 let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
4592 assert_eq!(
4593 restored.entries[0].chunk.qualified_name.as_deref(),
4594 Some("Service.alpha")
4595 );
4596 assert_eq!(
4597 restored.entries[1].chunk.qualified_name.as_deref(),
4598 Some("Service.beta")
4599 );
4600 assert_eq!(
4601 restored.fingerprint().unwrap().as_string(),
4602 fingerprint_before
4603 );
4604
4605 index.write_to_disk(storage.path(), "proj");
4606 let data_path = storage.path().join("semantic/proj/semantic.bin");
4607 let persisted = fs::read(&data_path).expect("read semantic.bin");
4608 assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
4609
4610 let loaded = SemanticIndex::read_from_disk(
4611 storage.path(),
4612 "proj",
4613 &project_root,
4614 false,
4615 Some(&fingerprint_before),
4616 )
4617 .expect("load semantic index");
4618 assert_eq!(loaded.entries.len(), index.entries.len());
4619 assert_eq!(loaded.dimension, index.dimension);
4620 assert_eq!(
4621 loaded.fingerprint().unwrap().as_string(),
4622 fingerprint_before
4623 );
4624 assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
4625 assert_eq!(loaded.file_sizes.get(&file), Some(&42));
4626 assert_eq!(
4627 loaded.file_hashes.get(&file),
4628 Some(&cache_freshness::zero_hash())
4629 );
4630 for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
4631 assert_eq!(actual.chunk.file, expected.chunk.file);
4632 assert_eq!(actual.chunk.name, expected.chunk.name);
4633 assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
4634 assert_eq!(actual.chunk.kind, expected.chunk.kind);
4635 assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
4636 assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
4637 assert_eq!(actual.chunk.exported, expected.chunk.exported);
4638 assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
4639 assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
4640 assert_eq!(actual.vector, expected.vector);
4641 }
4642 assert_eq!(loaded.to_bytes(), persisted);
4643 assert_eq!(fingerprint.as_string(), fingerprint_before);
4644 }
4645
4646 #[test]
4647 fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
4648 let cases = [
4649 (SymbolKind::Function, 0),
4650 (SymbolKind::Class, 1),
4651 (SymbolKind::Method, 2),
4652 (SymbolKind::Struct, 3),
4653 (SymbolKind::Interface, 4),
4654 (SymbolKind::Enum, 5),
4655 (SymbolKind::TypeAlias, 6),
4656 (SymbolKind::Variable, 7),
4657 (SymbolKind::Heading, 8),
4658 (SymbolKind::FileSummary, 9),
4659 ];
4660
4661 for (kind, encoded) in cases {
4662 assert_eq!(symbol_kind_to_u8(&kind), encoded);
4663 assert_eq!(u8_to_symbol_kind(encoded), kind);
4664 }
4665 }
4666
4667 #[test]
4668 fn test_search_top_k() {
4669 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
4670 index.dimension = 3;
4671
4672 for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
4674 let mut vec = vec![0.0f32; 3];
4675 vec[i] = 1.0; index.entries.push(EmbeddingEntry {
4677 chunk: SemanticChunk {
4678 file: PathBuf::from("/src/lib.rs"),
4679 name: name.to_string(),
4680 qualified_name: None,
4681 kind: SymbolKind::Function,
4682 start_line: (i * 10 + 1) as u32,
4683 end_line: (i * 10 + 5) as u32,
4684 exported: true,
4685 embed_text: format!("kind:function name:{}", name),
4686 snippet: format!("fn {}() {{}}", name),
4687 },
4688 vector: vec,
4689 });
4690 }
4691
4692 let query = vec![0.9, 0.1, 0.0];
4694 let results = index.search(&query, 2);
4695
4696 assert_eq!(results.len(), 2);
4697 assert_eq!(results[0].name, "auth"); assert!(results[0].score > results[1].score);
4699 }
4700
4701 #[test]
4702 fn test_empty_index_search() {
4703 let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
4704 let results = index.search(&[0.1, 0.2, 0.3], 10);
4705 assert!(results.is_empty());
4706 }
4707
4708 #[test]
4709 fn single_line_symbol_builds_non_empty_snippet() {
4710 let symbol = Symbol {
4711 name: "answer".to_string(),
4712 kind: SymbolKind::Variable,
4713 range: crate::symbols::Range {
4714 start_line: 0,
4715 start_col: 0,
4716 end_line: 0,
4717 end_col: 24,
4718 },
4719 signature: Some("const answer = 42".to_string()),
4720 scope_chain: Vec::new(),
4721 exported: true,
4722 parent: None,
4723 };
4724 let source = "export const answer = 42;\n";
4725
4726 let snippet = build_snippet(&symbol, source);
4727
4728 assert_eq!(snippet, "export const answer = 42;");
4729 }
4730
4731 #[test]
4732 fn optimized_file_chunk_collection_matches_file_parser_path() {
4733 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4734 let file = project_root.join("src/semantic_index.rs");
4735 let source = std::fs::read_to_string(&file).unwrap();
4736
4737 let mut legacy_parser = FileParser::new();
4738 let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
4739 let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
4740
4741 let mut parsers = HashMap::new();
4742 let optimized_chunks = collect_file_chunks(&project_root, &file, &mut parsers).unwrap();
4743
4744 assert_eq!(
4745 chunk_fingerprint(&optimized_chunks),
4746 chunk_fingerprint(&legacy_chunks)
4747 );
4748 }
4749
4750 #[test]
4751 fn collect_file_chunks_indexes_java_symbols() {
4752 let dir = tempfile::tempdir().unwrap();
4753 let file = dir.path().join("Greeter.java");
4754 std::fs::write(
4755 &file,
4756 r#"package example;
4757
4758public class Greeter {
4759 public String greet(String name) {
4760 return "Hello, " + name;
4761 }
4762}
4763"#,
4764 )
4765 .unwrap();
4766
4767 let mut parsers = HashMap::new();
4768 let chunks = collect_file_chunks(dir.path(), &file, &mut parsers).unwrap();
4769
4770 assert!(
4771 !chunks.is_empty(),
4772 "Java file should produce semantic chunks"
4773 );
4774 assert!(
4775 chunks
4776 .iter()
4777 .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
4778 "Java class symbol should be chunked: {chunks:?}"
4779 );
4780 assert!(
4781 chunks
4782 .iter()
4783 .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
4784 "Java method symbol should be chunked: {chunks:?}"
4785 );
4786 }
4787
4788 fn chunk_fingerprint(
4789 chunks: &[SemanticChunk],
4790 ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
4791 chunks
4792 .iter()
4793 .map(|chunk| {
4794 (
4795 chunk.name.clone(),
4796 chunk.kind.clone(),
4797 chunk.start_line,
4798 chunk.end_line,
4799 chunk.exported,
4800 chunk.embed_text.clone(),
4801 chunk.snippet.clone(),
4802 )
4803 })
4804 .collect()
4805 }
4806
4807 #[test]
4808 fn collect_file_chunks_skips_oversized_file() {
4809 let dir = tempfile::tempdir().unwrap();
4810 let big = dir.path().join("huge.ts");
4811 let filler = "export const x = 1;\n"
4813 .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
4814 std::fs::write(&big, &filler).unwrap();
4815 assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
4816
4817 let mut parsers = HashMap::new();
4818 let chunks = collect_file_chunks(dir.path(), &big, &mut parsers).unwrap();
4821 assert!(chunks.is_empty(), "oversized file must yield no chunks");
4822
4823 let small = dir.path().join("small.ts");
4825 std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
4826 let small_chunks = collect_file_chunks(dir.path(), &small, &mut parsers).unwrap();
4827 assert!(!small_chunks.is_empty(), "small file should still chunk");
4828 }
4829
4830 #[test]
4831 fn rejects_oversized_dimension_during_deserialization() {
4832 let mut bytes = Vec::new();
4833 bytes.push(1u8);
4834 bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
4835 bytes.extend_from_slice(&0u32.to_le_bytes());
4836 bytes.extend_from_slice(&0u32.to_le_bytes());
4837
4838 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
4839 }
4840
4841 #[test]
4842 fn rejects_oversized_entry_count_during_deserialization() {
4843 let mut bytes = Vec::new();
4844 bytes.push(1u8);
4845 bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
4846 bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
4847 bytes.extend_from_slice(&0u32.to_le_bytes());
4848
4849 assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
4850 }
4851
4852 #[test]
4853 fn invalidate_file_removes_entries_and_mtime() {
4854 let target = PathBuf::from("/src/main.rs");
4855 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
4856 index.entries.push(EmbeddingEntry {
4857 chunk: SemanticChunk {
4858 file: target.clone(),
4859 name: "main".to_string(),
4860 qualified_name: None,
4861 kind: SymbolKind::Function,
4862 start_line: 0,
4863 end_line: 1,
4864 exported: false,
4865 embed_text: "main".to_string(),
4866 snippet: "fn main() {}".to_string(),
4867 },
4868 vector: vec![1.0; DEFAULT_DIMENSION],
4869 });
4870 index
4871 .file_mtimes
4872 .insert(target.clone(), SystemTime::UNIX_EPOCH);
4873 index.file_sizes.insert(target.clone(), 0);
4874
4875 index.invalidate_file(&target);
4876
4877 assert!(index.entries.is_empty());
4878 assert!(!index.file_mtimes.contains_key(&target));
4879 assert!(!index.file_sizes.contains_key(&target));
4880 }
4881
4882 #[test]
4883 fn refresh_missing_changed_file_is_purged_after_collect() {
4884 let temp = tempfile::tempdir().unwrap();
4885 let project_root = temp.path();
4886 let file = project_root.join("src/lib.rs");
4887 fs::create_dir_all(file.parent().unwrap()).unwrap();
4888 write_rust_file(&file, "vanished_symbol");
4889
4890 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
4891 let original_size = *index.file_sizes.get(&file).unwrap();
4892 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
4893 fs::remove_file(&file).unwrap();
4894
4895 let mut embed = test_vector_for_texts;
4896 let mut progress = |_done: usize, _total: usize| {};
4897 let summary = index
4898 .refresh_stale_files(
4899 project_root,
4900 std::slice::from_ref(&file),
4901 &mut embed,
4902 8,
4903 &mut progress,
4904 )
4905 .unwrap();
4906
4907 assert_eq!(summary.changed, 0);
4908 assert_eq!(summary.added, 0);
4909 assert_eq!(summary.deleted, 1);
4910 assert!(index.entries.is_empty());
4911 assert!(!index.file_mtimes.contains_key(&file));
4912 assert!(!index.file_sizes.contains_key(&file));
4913 assert!(!index.file_hashes.contains_key(&file));
4914 }
4915
4916 #[test]
4917 fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
4918 let temp = tempfile::tempdir().unwrap();
4919 let project_root = temp.path();
4920 let file = project_root.join("src/lib.rs");
4921 fs::create_dir_all(file.parent().unwrap()).unwrap();
4922 write_rust_file(&file, "kept_symbol");
4923
4924 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
4925 let original_entry_count = index.entries.len();
4926 let original_mtime = *index.file_mtimes.get(&file).unwrap();
4927 let original_size = *index.file_sizes.get(&file).unwrap();
4928
4929 let stale_mtime = SystemTime::UNIX_EPOCH;
4930 set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
4931 fs::remove_file(&file).unwrap();
4932 fs::create_dir(&file).unwrap();
4933
4934 let mut embed = test_vector_for_texts;
4935 let mut progress = |_done: usize, _total: usize| {};
4936 let summary = index
4937 .refresh_stale_files(
4938 project_root,
4939 std::slice::from_ref(&file),
4940 &mut embed,
4941 8,
4942 &mut progress,
4943 )
4944 .unwrap();
4945
4946 assert_eq!(summary.changed, 0);
4947 assert_eq!(summary.added, 0);
4948 assert_eq!(summary.deleted, 0);
4949 assert_eq!(index.entries.len(), original_entry_count);
4950 assert!(index
4951 .entries
4952 .iter()
4953 .any(|entry| entry.chunk.name == "kept_symbol"));
4954 assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
4955 assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
4956 assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
4957 }
4958
4959 #[test]
4960 fn refresh_never_indexed_file_error_does_not_record_mtime() {
4961 let temp = tempfile::tempdir().unwrap();
4962 let project_root = temp.path();
4963 let missing = project_root.join("src/missing.rs");
4964 fs::create_dir_all(missing.parent().unwrap()).unwrap();
4965
4966 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
4967 let mut embed = test_vector_for_texts;
4968 let mut progress = |_done: usize, _total: usize| {};
4969 let summary = index
4970 .refresh_stale_files(
4971 project_root,
4972 std::slice::from_ref(&missing),
4973 &mut embed,
4974 8,
4975 &mut progress,
4976 )
4977 .unwrap();
4978
4979 assert_eq!(summary.added, 0);
4980 assert_eq!(summary.changed, 0);
4981 assert_eq!(summary.deleted, 0);
4982 assert!(!index.file_mtimes.contains_key(&missing));
4983 assert!(!index.file_sizes.contains_key(&missing));
4984 assert!(index.entries.is_empty());
4985 }
4986
4987 #[test]
4988 fn refresh_reports_added_for_new_files() {
4989 let temp = tempfile::tempdir().unwrap();
4990 let project_root = temp.path();
4991 let existing = project_root.join("src/lib.rs");
4992 let added = project_root.join("src/new.rs");
4993 fs::create_dir_all(existing.parent().unwrap()).unwrap();
4994 write_rust_file(&existing, "existing_symbol");
4995 write_rust_file(&added, "added_symbol");
4996
4997 let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
4998 let mut embed = test_vector_for_texts;
4999 let mut progress = |_done: usize, _total: usize| {};
5000 let summary = index
5001 .refresh_stale_files(
5002 project_root,
5003 &[existing.clone(), added.clone()],
5004 &mut embed,
5005 8,
5006 &mut progress,
5007 )
5008 .unwrap();
5009
5010 assert_eq!(summary.added, 1);
5011 assert_eq!(summary.changed, 0);
5012 assert_eq!(summary.deleted, 0);
5013 assert_eq!(summary.total_processed, 2);
5014 assert!(index.file_mtimes.contains_key(&added));
5015 assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
5016 }
5017
5018 #[test]
5019 fn refresh_reports_deleted_for_removed_files() {
5020 let temp = tempfile::tempdir().unwrap();
5021 let project_root = temp.path();
5022 let deleted = project_root.join("src/deleted.rs");
5023 fs::create_dir_all(deleted.parent().unwrap()).unwrap();
5024 write_rust_file(&deleted, "deleted_symbol");
5025
5026 let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
5027 fs::remove_file(&deleted).unwrap();
5028
5029 let mut embed = test_vector_for_texts;
5030 let mut progress = |_done: usize, _total: usize| {};
5031 let summary = index
5032 .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
5033 .unwrap();
5034
5035 assert_eq!(summary.deleted, 1);
5036 assert_eq!(summary.changed, 0);
5037 assert_eq!(summary.added, 0);
5038 assert_eq!(summary.total_processed, 1);
5039 assert!(!index.file_mtimes.contains_key(&deleted));
5040 assert!(index.entries.is_empty());
5041 }
5042
5043 #[test]
5044 fn refresh_reports_changed_for_modified_files() {
5045 let temp = tempfile::tempdir().unwrap();
5046 let project_root = temp.path();
5047 let file = project_root.join("src/lib.rs");
5048 fs::create_dir_all(file.parent().unwrap()).unwrap();
5049 write_rust_file(&file, "old_symbol");
5050
5051 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
5052 set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
5053 write_rust_file(&file, "new_symbol");
5054
5055 let mut embed = test_vector_for_texts;
5056 let mut progress = |_done: usize, _total: usize| {};
5057 let summary = index
5058 .refresh_stale_files(
5059 project_root,
5060 std::slice::from_ref(&file),
5061 &mut embed,
5062 8,
5063 &mut progress,
5064 )
5065 .unwrap();
5066
5067 assert_eq!(summary.changed, 1);
5068 assert_eq!(summary.added, 0);
5069 assert_eq!(summary.deleted, 0);
5070 assert_eq!(summary.total_processed, 1);
5071 assert!(index
5072 .entries
5073 .iter()
5074 .any(|entry| entry.chunk.name == "new_symbol"));
5075 assert!(!index
5076 .entries
5077 .iter()
5078 .any(|entry| entry.chunk.name == "old_symbol"));
5079 }
5080
5081 #[test]
5082 fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
5083 let temp = tempfile::tempdir().unwrap();
5084 let project_root = temp.path();
5085 let file = project_root.join("src/lib.rs");
5086 fs::create_dir_all(file.parent().unwrap()).unwrap();
5087 write_rust_file(&file, "clean_symbol");
5088
5089 let mut index = build_test_index(project_root, std::slice::from_ref(&file));
5090 let original_entries = index.entries.len();
5091 let mut embed_called = false;
5092 let mut embed = |texts: Vec<String>| {
5093 embed_called = true;
5094 test_vector_for_texts(texts)
5095 };
5096 let mut progress = |_done: usize, _total: usize| {};
5097 let summary = index
5098 .refresh_stale_files(
5099 project_root,
5100 std::slice::from_ref(&file),
5101 &mut embed,
5102 8,
5103 &mut progress,
5104 )
5105 .unwrap();
5106
5107 assert!(summary.is_noop());
5108 assert_eq!(summary.total_processed, 1);
5109 assert!(!embed_called);
5110 assert_eq!(index.entries.len(), original_entries);
5111 }
5112
5113 #[test]
5114 fn detects_missing_onnx_runtime_from_dynamic_load_error() {
5115 let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
5116
5117 assert!(is_onnx_runtime_unavailable(message));
5118 }
5119
5120 #[test]
5121 fn formats_missing_onnx_runtime_with_install_hint() {
5122 let message = format_embedding_init_error(
5123 "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
5124 );
5125
5126 assert!(message.starts_with("ONNX Runtime not found. Install via:"));
5127 assert!(message.contains("Original error:"));
5128 }
5129
5130 #[test]
5131 fn interactive_query_embedding_model_caps_remote_timeout() {
5132 let mut config = SemanticBackendConfig {
5133 backend: SemanticBackend::OpenAiCompatible,
5134 model: "test-embedding".to_string(),
5135 base_url: Some("http://127.0.0.1:9".to_string()),
5136 api_key_env: None,
5137 timeout_ms: 0,
5138 max_batch_size: 64,
5139 max_files: 20_000,
5140 };
5141
5142 let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
5143 let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
5144 assert_eq!(
5145 build_model.timeout_ms(),
5146 DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
5147 "background build keeps the longer default embedding timeout"
5148 );
5149 assert_eq!(
5150 query_model.timeout_ms(),
5151 DEFAULT_QUERY_EMBEDDING_TIMEOUT_MS,
5152 "interactive query embedding is capped below the dispatch transport timeout"
5153 );
5154
5155 config.timeout_ms = 60_000;
5156 let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
5157 assert_eq!(
5158 query_model.timeout_ms(),
5159 DEFAULT_QUERY_EMBEDDING_TIMEOUT_MS,
5160 "explicitly long backend timeouts are capped for interactive queries"
5161 );
5162
5163 config.timeout_ms = 3_000;
5164 let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
5165 assert_eq!(
5166 query_model.timeout_ms(),
5167 3_000,
5168 "shorter explicit timeouts are respected for interactive queries"
5169 );
5170 }
5171
5172 #[test]
5173 fn openai_compatible_backend_embeds_with_mock_server() {
5174 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
5175 assert!(request_line.starts_with("POST "));
5176 assert_eq!(path, "/v1/embeddings");
5177 "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
5178 });
5179
5180 let config = SemanticBackendConfig {
5181 backend: SemanticBackend::OpenAiCompatible,
5182 model: "test-embedding".to_string(),
5183 base_url: Some(base_url),
5184 api_key_env: None,
5185 timeout_ms: 5_000,
5186 max_batch_size: 64,
5187 max_files: 20_000,
5188 };
5189
5190 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
5191 let vectors = model
5192 .embed(vec!["hello".to_string(), "world".to_string()])
5193 .unwrap();
5194
5195 assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
5196 handle.join().unwrap();
5197 }
5198
5199 #[test]
5209 fn openai_compatible_request_has_single_content_type_header() {
5210 use std::sync::{Arc, Mutex};
5211 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
5212 let captured_for_thread = Arc::clone(&captured);
5213
5214 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
5215 let addr = listener.local_addr().expect("local addr");
5216 let handle = thread::spawn(move || {
5217 let (mut stream, _) = listener.accept().expect("accept");
5218 let mut buf = Vec::new();
5219 let mut chunk = [0u8; 4096];
5220 let mut header_end = None;
5221 let mut content_length = 0usize;
5222 loop {
5223 let n = stream.read(&mut chunk).expect("read");
5224 if n == 0 {
5225 break;
5226 }
5227 buf.extend_from_slice(&chunk[..n]);
5228 if header_end.is_none() {
5229 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
5230 header_end = Some(pos + 4);
5231 for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
5232 if let Some(value) = line.strip_prefix("Content-Length:") {
5233 content_length = value.trim().parse::<usize>().unwrap_or(0);
5234 }
5235 }
5236 }
5237 }
5238 if let Some(end) = header_end {
5239 if buf.len() >= end + content_length {
5240 break;
5241 }
5242 }
5243 }
5244 *captured_for_thread.lock().unwrap() = buf;
5245 let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
5246 let response = format!(
5247 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5248 body.len(),
5249 body
5250 );
5251 let _ = stream.write_all(response.as_bytes());
5252 });
5253
5254 let config = SemanticBackendConfig {
5255 backend: SemanticBackend::OpenAiCompatible,
5256 model: "text-embedding-3-small".to_string(),
5257 base_url: Some(format!("http://{}", addr)),
5258 api_key_env: None,
5259 timeout_ms: 5_000,
5260 max_batch_size: 64,
5261 max_files: 20_000,
5262 };
5263 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
5264 let _ = model.embed(vec!["probe".to_string()]).unwrap();
5265 handle.join().unwrap();
5266
5267 let bytes = captured.lock().unwrap().clone();
5268 let request = String::from_utf8_lossy(&bytes);
5269
5270 let content_type_lines = request
5273 .lines()
5274 .filter(|line| {
5275 let lower = line.to_ascii_lowercase();
5276 lower.starts_with("content-type:")
5277 })
5278 .count();
5279 assert_eq!(
5280 content_type_lines, 1,
5281 "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
5282 );
5283
5284 assert!(
5287 request.contains(r#""model":"text-embedding-3-small""#),
5288 "request body should contain model field; full request:\n{request}",
5289 );
5290 }
5291
5292 #[test]
5293 fn ollama_backend_embeds_with_mock_server() {
5294 let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
5295 assert!(request_line.starts_with("POST "));
5296 assert_eq!(path, "/api/embed");
5297 "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
5298 });
5299
5300 let config = SemanticBackendConfig {
5301 backend: SemanticBackend::Ollama,
5302 model: "embeddinggemma".to_string(),
5303 base_url: Some(base_url),
5304 api_key_env: None,
5305 timeout_ms: 5_000,
5306 max_batch_size: 64,
5307 max_files: 20_000,
5308 };
5309
5310 let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
5311 let vectors = model
5312 .embed(vec!["hello".to_string(), "world".to_string()])
5313 .unwrap();
5314
5315 assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
5316 handle.join().unwrap();
5317 }
5318
5319 #[test]
5320 fn read_from_disk_rejects_fingerprint_mismatch() {
5321 let storage = tempfile::tempdir().unwrap();
5322 let project_key = "proj";
5323
5324 let project_root = test_project_root();
5325 let file = project_root.join("src/main.rs");
5326 let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
5327 index.entries.push(EmbeddingEntry {
5328 chunk: SemanticChunk {
5329 file: file.clone(),
5330 name: "handle_request".to_string(),
5331 qualified_name: None,
5332 kind: SymbolKind::Function,
5333 start_line: 10,
5334 end_line: 25,
5335 exported: true,
5336 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
5337 snippet: "fn handle_request() {}".to_string(),
5338 },
5339 vector: vec![0.1, 0.2, 0.3],
5340 });
5341 index.dimension = 3;
5342 index
5343 .file_mtimes
5344 .insert(file.clone(), SystemTime::UNIX_EPOCH);
5345 index.file_sizes.insert(file, 0);
5346 index.set_fingerprint(SemanticIndexFingerprint {
5347 backend: "openai_compatible".to_string(),
5348 model: "test-embedding".to_string(),
5349 base_url: "http://127.0.0.1:1234/v1".to_string(),
5350 dimension: 3,
5351 chunking_version: default_chunking_version(),
5352 });
5353 index.write_to_disk(storage.path(), project_key);
5354
5355 let matching = index.fingerprint().unwrap().as_string();
5356 assert!(SemanticIndex::read_from_disk(
5357 storage.path(),
5358 project_key,
5359 &project_root,
5360 false,
5361 Some(&matching),
5362 )
5363 .is_some());
5364
5365 let mismatched = SemanticIndexFingerprint {
5366 backend: "ollama".to_string(),
5367 model: "embeddinggemma".to_string(),
5368 base_url: "http://127.0.0.1:11434".to_string(),
5369 dimension: 3,
5370 chunking_version: default_chunking_version(),
5371 }
5372 .as_string();
5373 assert!(SemanticIndex::read_from_disk(
5374 storage.path(),
5375 project_key,
5376 &project_root,
5377 false,
5378 Some(&mismatched),
5379 )
5380 .is_none());
5381 }
5382
5383 #[test]
5384 fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
5385 let storage = tempfile::tempdir().unwrap();
5386 let project_key = "proj-v3";
5387 let dir = storage.path().join("semantic").join(project_key);
5388 fs::create_dir_all(&dir).unwrap();
5389
5390 let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
5391 index.entries.push(EmbeddingEntry {
5392 chunk: SemanticChunk {
5393 file: PathBuf::from("/src/main.rs"),
5394 name: "handle_request".to_string(),
5395 qualified_name: None,
5396 kind: SymbolKind::Function,
5397 start_line: 0,
5398 end_line: 0,
5399 exported: true,
5400 embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
5401 snippet: "fn handle_request() {}".to_string(),
5402 },
5403 vector: vec![0.1, 0.2, 0.3],
5404 });
5405 index.dimension = 3;
5406 index
5407 .file_mtimes
5408 .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
5409 index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
5410 let fingerprint = SemanticIndexFingerprint {
5411 backend: "fastembed".to_string(),
5412 model: "test".to_string(),
5413 base_url: FALLBACK_BACKEND.to_string(),
5414 dimension: 3,
5415 chunking_version: default_chunking_version(),
5416 };
5417 index.set_fingerprint(fingerprint.clone());
5418
5419 let mut bytes = index.to_bytes();
5420 bytes[0] = SEMANTIC_INDEX_VERSION_V3;
5421 fs::write(dir.join("semantic.bin"), bytes).unwrap();
5422
5423 assert!(SemanticIndex::read_from_disk(
5424 storage.path(),
5425 project_key,
5426 &test_project_root(),
5427 false,
5428 Some(&fingerprint.as_string())
5429 )
5430 .is_none());
5431 assert!(!dir.join("semantic.bin").exists());
5432 }
5433
5434 fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
5435 crate::symbols::Symbol {
5436 name: name.to_string(),
5437 kind,
5438 range: crate::symbols::Range {
5439 start_line: start,
5440 start_col: 0,
5441 end_line: end,
5442 end_col: 0,
5443 },
5444 signature: None,
5445 scope_chain: Vec::new(),
5446 exported: false,
5447 parent: None,
5448 }
5449 }
5450
5451 #[test]
5452 fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
5453 let project_root = PathBuf::from("/proj");
5454 let file = project_root.join("src/engine.ts");
5455 let source = "class Index {\n}\n";
5456 let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
5457 symbol.scope_chain = vec!["Engine".to_string()];
5458 symbol.signature = Some("class Index".to_string());
5459 let embed_text = build_embed_text(&symbol, source, &file, &project_root);
5460
5461 let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
5462 let chunk = chunks
5463 .iter()
5464 .find(|chunk| chunk.name == "Index")
5465 .expect("class chunk");
5466
5467 assert_eq!(chunk.name, "Index");
5468 assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
5469 assert_eq!(chunk.embed_text, embed_text);
5470 assert!(!chunk.embed_text.contains("Engine.Index"));
5471 }
5472
5473 #[test]
5478 fn symbols_to_chunks_skips_heading_symbols() {
5479 let project_root = PathBuf::from("/proj");
5480 let file = project_root.join("README.md");
5481 let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
5482
5483 let symbols = vec![
5484 make_symbol(SymbolKind::Heading, "Title", 0, 2),
5485 make_symbol(SymbolKind::Heading, "Section", 4, 6),
5486 ];
5487
5488 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
5489 assert!(
5490 chunks.is_empty(),
5491 "Heading symbols must be filtered out before embedding; got {} chunk(s)",
5492 chunks.len()
5493 );
5494 }
5495
5496 #[test]
5503 fn build_embed_text_clamps_oversized_signature() {
5504 let project_root = PathBuf::from("/proj");
5505 let file = project_root.join("cronjob.yaml");
5506 let huge_sig = "kubectl ".repeat(2000); let source = "apiVersion: batch/v1\nkind: CronJob\n";
5508
5509 let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
5510 symbol.signature = Some(huge_sig);
5511
5512 let text = build_embed_text(&symbol, source, &file, &project_root);
5513 assert!(
5514 text.chars().count() <= MAX_EMBED_TEXT_CHARS,
5515 "embed_text must be clamped to {} chars, got {}",
5516 MAX_EMBED_TEXT_CHARS,
5517 text.chars().count()
5518 );
5519 }
5520
5521 #[test]
5525 fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
5526 let project_root = PathBuf::from("/proj");
5527 let file = project_root.join("src/lib.rs");
5528 let source = "pub fn handle_request() -> bool {\n true\n}\n";
5529
5530 let symbols = vec![
5531 make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
5533 make_symbol(SymbolKind::Function, "handle_request", 0, 2),
5534 make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
5535 ];
5536
5537 let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
5538 assert_eq!(
5539 chunks.len(),
5540 3,
5541 "Expected file-summary + 2 code chunks (Function + Struct), got {}",
5542 chunks.len()
5543 );
5544 let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
5545 assert!(chunks
5546 .iter()
5547 .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
5548 assert!(names.contains(&"handle_request"));
5549 assert!(names.contains(&"AuthService"));
5550 assert!(
5551 !names.contains(&"doc heading"),
5552 "Heading symbol leaked into chunks: {names:?}"
5553 );
5554 }
5555
5556 #[test]
5557 fn validate_ssrf_allows_loopback_hostnames() {
5558 for host in &[
5561 "http://localhost",
5562 "http://localhost:8080",
5563 "http://localhost:11434", "http://localhost.localdomain",
5565 "http://foo.localhost",
5566 ] {
5567 assert!(
5568 validate_base_url_no_ssrf(host).is_ok(),
5569 "Expected {host} to be allowed (loopback), got: {:?}",
5570 validate_base_url_no_ssrf(host)
5571 );
5572 }
5573 }
5574
5575 #[test]
5576 fn validate_ssrf_allows_loopback_ips() {
5577 for url in &[
5580 "http://127.0.0.1",
5581 "http://127.0.0.1:11434", "http://127.0.0.1:8080",
5583 "http://127.1.2.3",
5584 ] {
5585 let result = validate_base_url_no_ssrf(url);
5586 assert!(
5587 result.is_ok(),
5588 "Expected {url} to be allowed (loopback), got: {:?}",
5589 result
5590 );
5591 }
5592 }
5593
5594 #[test]
5595 fn validate_ssrf_rejects_private_non_loopback_ips() {
5596 for url in &[
5601 "http://192.168.1.1",
5602 "http://10.0.0.1",
5603 "http://172.16.0.1",
5604 "http://169.254.169.254",
5605 "http://100.64.0.1",
5606 ] {
5607 let result = validate_base_url_no_ssrf(url);
5608 assert!(
5609 result.is_err(),
5610 "Expected {url} to be rejected (non-loopback private), got: {:?}",
5611 result
5612 );
5613 }
5614 }
5615
5616 #[test]
5617 fn validate_ssrf_rejects_mdns_local_hostnames() {
5618 for host in &[
5621 "http://printer.local",
5622 "http://nas.local:8080",
5623 "http://homelab.local",
5624 ] {
5625 let result = validate_base_url_no_ssrf(host);
5626 assert!(
5627 result.is_err(),
5628 "Expected {host} to be rejected (mDNS), got: {:?}",
5629 result
5630 );
5631 }
5632 }
5633
5634 #[test]
5635 fn normalize_base_url_allows_localhost_for_tests() {
5636 assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
5639 assert!(normalize_base_url("http://localhost:8080").is_ok());
5640 }
5641
5642 #[test]
5643 fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
5644 use std::net::IpAddr;
5645 let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
5646
5647 assert!(blocked("10.0.0.1"));
5649 assert!(blocked("192.168.1.1"));
5650 assert!(blocked("169.254.0.1"));
5651 assert!(blocked("100.64.0.1"));
5652 assert!(
5654 blocked("198.18.0.1"),
5655 "RFC2544 benchmark range must be blocked"
5656 );
5657 assert!(blocked("224.0.0.1"), "multicast must be blocked");
5658 assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
5659 assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
5660
5661 assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
5663 assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
5664 assert!(
5665 !blocked("::ffff:127.0.0.1"),
5666 "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
5667 );
5668
5669 assert!(!blocked("8.8.8.8"));
5671 }
5672
5673 #[test]
5680 fn ort_mismatch_message_recommends_auto_fix_first() {
5681 let msg =
5682 format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
5683
5684 assert!(
5686 msg.contains("v1.9.0"),
5687 "should report detected version: {msg}"
5688 );
5689 assert!(
5690 msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
5691 "should report system path: {msg}"
5692 );
5693 assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
5694
5695 let auto_fix_pos = msg
5697 .find("Auto-fix")
5698 .expect("Auto-fix solution missing — users won't discover --fix");
5699 let remove_pos = msg
5700 .find("Remove the old library")
5701 .expect("system-rm solution missing");
5702 assert!(
5703 auto_fix_pos < remove_pos,
5704 "Auto-fix must come before manual rm — see PR comment thread"
5705 );
5706
5707 assert!(
5709 msg.contains("npx @cortexkit/aft doctor --fix"),
5710 "auto-fix command must be present and copy-pasteable: {msg}"
5711 );
5712 }
5713
5714 #[cfg(any(target_os = "linux", target_os = "macos"))]
5715 #[test]
5716 fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
5717 let requested = "libonnxruntime.so";
5718 let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
5719
5720 assert_eq!(detect_ort_version_from_path(requested), None);
5721 let (version, source) =
5722 detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
5723
5724 assert_eq!(version, Some("1.19.0".to_string()));
5725 assert_eq!(source, actual);
5726
5727 let msg = format_ort_version_mismatch(&version.unwrap(), &source);
5728 assert!(msg.contains("v1.19.0"));
5729 assert!(msg.contains(actual));
5730 }
5731
5732 #[test]
5736 fn ort_mismatch_message_handles_macos_dylib_path() {
5737 let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
5738 assert!(msg.contains("v1.9.0"));
5739 assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
5740 assert!(
5744 msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
5745 "system path should be quoted in the auto-fix sentence: {msg}"
5746 );
5747 }
5748}