1use super::display::{print_tool_call, print_tool_output};
13use async_trait::async_trait;
14use std::sync::Mutex;
15use std::time::Duration;
16
17#[async_trait]
21pub trait Embedder: Send + Sync {
22 async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>>;
24}
25
26pub struct EmbeddingsClient {
34 url: String,
35 model: String,
36 kind: crate::BackendKind,
37 api_key: Option<String>,
38 timeout_secs: u64,
39 retries: u32,
40}
41
42impl EmbeddingsClient {
43 pub fn new(
44 url: impl Into<String>,
45 model: impl Into<String>,
46 kind: crate::BackendKind,
47 api_key: Option<String>,
48 timeout_secs: u64,
49 retries: u32,
50 ) -> Self {
51 Self {
52 url: url.into(),
53 model: model.into(),
54 kind,
55 api_key,
56 timeout_secs,
57 retries,
58 }
59 }
60
61 pub async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
64 let mut out = Vec::with_capacity(texts.len());
65 for t in texts {
66 out.push(self.embed(t).await?);
67 }
68 Ok(out)
69 }
70
71 async fn embed_once(&self, text: &str) -> anyhow::Result<Vec<f32>> {
74 let base = self.url.trim_end_matches('/');
75 let (endpoint, body) = match self.kind {
76 crate::BackendKind::Ollama => (
77 format!("{base}/api/embeddings"),
78 serde_json::json!({ "model": self.model, "prompt": text }),
79 ),
80 crate::BackendKind::Openai => (
81 format!("{base}/v1/embeddings"),
82 serde_json::json!({ "model": self.model, "input": text }),
83 ),
84 crate::BackendKind::Embedded => anyhow::bail!(
85 "the embedded backend is chat-only and does not serve embeddings; \
86 set `embeddings_api` to an ollama/openai backend"
87 ),
88 };
89 let client = reqwest::Client::builder()
90 .timeout(Duration::from_secs(self.timeout_secs))
91 .build()?;
92 let mut req = client.post(&endpoint).json(&body);
93 if let Some(key) = &self.api_key {
94 req = req.bearer_auth(key);
95 }
96 let resp = req.send().await?;
97 if !resp.status().is_success() {
98 anyhow::bail!("embeddings endpoint {endpoint} returned {}", resp.status());
99 }
100 let json: serde_json::Value = resp.json().await?;
101 let arr = match self.kind {
103 crate::BackendKind::Ollama => json["embedding"].as_array(),
104 crate::BackendKind::Openai => json["data"][0]["embedding"].as_array(),
105 crate::BackendKind::Embedded => None,
107 }
108 .ok_or_else(|| anyhow::anyhow!("embeddings response missing `embedding` array"))?;
109 let vec: Vec<f32> = arr
110 .iter()
111 .map(|v| v.as_f64().unwrap_or(0.0) as f32)
112 .collect();
113 if vec.is_empty() {
114 anyhow::bail!("embeddings response had an empty vector");
115 }
116 Ok(vec)
117 }
118}
119
120#[async_trait]
121impl Embedder for EmbeddingsClient {
122 async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
123 let mut last_err = None;
124 for attempt in 0..=self.retries {
125 if attempt > 0 {
126 let backoff = Duration::from_millis(250u64 << (attempt - 1).min(4));
128 tokio::time::sleep(backoff).await;
129 }
130 match self.embed_once(text).await {
131 Ok(v) => return Ok(v),
132 Err(e) => last_err = Some(e),
133 }
134 }
135 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("embeddings failed")))
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct CodeChunk {
145 pub file: String,
146 pub start_line: usize,
148 pub end_line: usize,
150 pub kind: String,
152 pub text: String,
153}
154
155pub const CHUNK_MAX_CHARS: usize = 2_000;
157const WINDOW_LINES: usize = 40;
159
160pub fn chunk_source(file: &str, source: &str) -> Vec<CodeChunk> {
167 let lines: Vec<&str> = source.lines().collect();
168 if lines.is_empty() {
169 return Vec::new();
170 }
171 let mut defs = crate::symbols::Lang::from_path(file)
172 .map(|lang| crate::symbols::extract_definitions(source, lang))
173 .unwrap_or_default();
174 if defs.is_empty() {
175 return window_chunks(file, &lines, 1, lines.len());
176 }
177 defs.sort_by_key(|d| d.line);
178 let starts: Vec<usize> = defs.iter().map(|d| block_start(&lines, d.line)).collect();
180 let mut chunks = Vec::new();
181 for (i, d) in defs.iter().enumerate() {
182 let start = starts[i];
183 let end = if i + 1 < defs.len() {
184 starts[i + 1].saturating_sub(1).max(start)
185 } else {
186 lines.len()
187 };
188 let kind = format!("{:?}", d.kind).to_lowercase();
189 let text = join_lines(&lines, start, end);
190 if text.chars().count() > CHUNK_MAX_CHARS {
191 chunks.extend(window_chunks(file, &lines, start, end));
193 } else {
194 chunks.push(CodeChunk {
195 file: file.to_string(),
196 start_line: start,
197 end_line: end,
198 kind,
199 text,
200 });
201 }
202 }
203 chunks
204}
205
206fn block_start(lines: &[&str], def_line: usize) -> usize {
209 let is_doc = |s: &str| {
210 let t = s.trim_start();
211 t.starts_with("///")
212 || t.starts_with("//")
213 || t.starts_with('#')
214 || t.starts_with("\"\"\"")
215 || t.starts_with("/*")
216 || t.starts_with('*')
217 };
218 let mut start = def_line;
219 while start > 1 && is_doc(lines[start - 2]) {
220 start -= 1;
221 }
222 start
223}
224
225fn join_lines(lines: &[&str], start: usize, end: usize) -> String {
226 let end = end.min(lines.len());
227 if start > end {
228 return String::new();
229 }
230 lines[start - 1..end].join("\n")
231}
232
233fn window_chunks(file: &str, lines: &[&str], from: usize, to: usize) -> Vec<CodeChunk> {
235 let mut chunks = Vec::new();
236 let mut s = from;
237 while s <= to {
238 let e = (s + WINDOW_LINES - 1).min(to);
239 chunks.push(CodeChunk {
240 file: file.to_string(),
241 start_line: s,
242 end_line: e,
243 kind: "window".to_string(),
244 text: join_lines(lines, s, e),
245 });
246 s = e + 1;
247 }
248 chunks
249}
250
251pub(crate) const CODE_EVIDENCE_CAP: usize = 6_000;
256
257pub trait SemanticIndex: Send + Sync {
260 fn index_chunk(&self, chunk: CodeChunk, embedding: Vec<f32>);
262 fn search(&self, query: &[f32], top_k: usize) -> Vec<(f32, CodeChunk)>;
264 fn chunks_indexed(&self) -> u64;
266 fn indexed_chars(&self) -> u64;
268 fn clear(&self);
270}
271
272#[derive(Default)]
276pub struct SessionSemanticIndex {
277 entries: Mutex<Vec<(CodeChunk, Vec<f32>)>>,
278}
279
280impl SemanticIndex for SessionSemanticIndex {
281 fn index_chunk(&self, chunk: CodeChunk, embedding: Vec<f32>) {
282 self.entries.lock().unwrap().push((chunk, embedding));
283 }
284 fn search(&self, query: &[f32], top_k: usize) -> Vec<(f32, CodeChunk)> {
285 let entries = self.entries.lock().unwrap();
286 let mut scored: Vec<(f32, CodeChunk)> = entries
287 .iter()
288 .map(|(c, e)| (cosine(query, e), c.clone()))
289 .collect();
290 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
292 scored.truncate(top_k);
293 scored
294 }
295 fn chunks_indexed(&self) -> u64 {
296 self.entries.lock().unwrap().len() as u64
297 }
298 fn indexed_chars(&self) -> u64 {
299 self.entries
300 .lock()
301 .unwrap()
302 .iter()
303 .map(|(c, _)| c.text.chars().count() as u64)
304 .sum()
305 }
306 fn clear(&self) {
307 self.entries.lock().unwrap().clear();
308 }
309}
310
311pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
314 if a.is_empty() || a.len() != b.len() {
315 return 0.0;
316 }
317 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
318 let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
319 let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
320 if na == 0.0 || nb == 0.0 {
321 0.0
322 } else {
323 dot / (na * nb)
324 }
325}
326
327fn render_hits(hits: &[(f32, CodeChunk)], total_cap: usize) -> Option<String> {
332 if hits.is_empty() {
333 return None;
334 }
335 let mut body = String::from("<code_evidence>\n");
336 for (score, chunk) in hits {
337 let piece = format!(
338 "// {}:{}-{} ({}, score {score:.2})\n{}\n\n",
339 chunk.file, chunk.start_line, chunk.end_line, chunk.kind, chunk.text
340 );
341 if body.chars().count() + piece.chars().count() + "</code_evidence>".len() > total_cap {
342 body.push_str("[… more evidence omitted to fit the budget …]\n");
343 break;
344 }
345 body.push_str(&piece);
346 }
347 body.push_str("</code_evidence>");
348 Some(body)
349}
350
351pub(crate) fn build_code_evidence_block(
356 index: &dyn SemanticIndex,
357 query: &[f32],
358 top_k: usize,
359 total_cap: usize,
360) -> Option<String> {
361 render_hits(&index.search(query, top_k), total_cap)
362}
363
364pub fn code_evidence_block(
367 index: &dyn SemanticIndex,
368 query: &[f32],
369 top_k: usize,
370) -> Option<String> {
371 build_code_evidence_block(index, query, top_k, CODE_EVIDENCE_CAP)
372}
373
374pub async fn index_files(
383 files: &[(String, String)],
384 embedder: &dyn Embedder,
385 index: &dyn SemanticIndex,
386 on_failure: crate::OnEmbedFailure,
387) -> usize {
388 let mut indexed = 0;
389 for (file, source) in files {
390 for chunk in chunk_source(file, source) {
391 match embedder.embed(&chunk.text).await {
392 Ok(v) => {
393 index.index_chunk(chunk, v);
394 indexed += 1;
395 }
396 Err(e) => match on_failure {
397 crate::OnEmbedFailure::Disable => {
401 tracing::error!(
402 error = %e,
403 file = file.as_str(),
404 "semantic indexing disabled: embeddings failed. Configure \
405 [context.semantic] for a working embedder: use \
406 embeddings_endpoint/embeddings_api for an Ollama or OpenAI \
407 embeddings service, or embeddings_api = \"embedded\" with \
408 embedding_model_path for local in-process embeddings. Set \
409 on_embed_failure = \"warn\" to keep trying per-chunk. Indexed \
410 {indexed} chunk(s) before stopping."
411 );
412 return indexed;
413 }
414 crate::OnEmbedFailure::Warn => {
415 tracing::warn!(error = %e, file = file.as_str(), "embed failed; skipping chunk");
416 }
417 },
418 }
419 }
420 }
421 indexed
422}
423
424const RERANK_OVERFETCH: usize = 3;
430const DEF_BOOST: f32 = 0.05;
432const PATH_BOOST: f32 = 0.05;
434
435fn rerank(query: &str, hits: &mut [(f32, CodeChunk)]) {
442 let terms: Vec<String> = query
443 .split(|c: char| !c.is_alphanumeric())
444 .filter(|t| t.len() >= 3)
445 .map(str::to_lowercase)
446 .collect();
447 let boost = |chunk: &CodeChunk| -> f32 {
448 let mut b = 0.0;
449 if chunk.kind != "window" {
450 b += DEF_BOOST;
451 }
452 let file_lc = chunk.file.to_lowercase();
453 if terms.iter().any(|t| file_lc.contains(t.as_str())) {
454 b += PATH_BOOST;
455 }
456 b
457 };
458 hits.sort_by(|a, b| {
459 let sb = b.0 + boost(&b.1);
460 let sa = a.0 + boost(&a.1);
461 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
462 });
463}
464
465pub async fn retrieve_evidence(
471 query: &str,
472 embedder: &dyn Embedder,
473 index: &dyn SemanticIndex,
474 top_k: usize,
475) -> Option<String> {
476 let qv = embedder.embed(query).await.ok()?;
477 let mut hits = index.search(&qv, top_k.saturating_mul(RERANK_OVERFETCH));
478 rerank(query, &mut hits);
479 hits.truncate(top_k);
480 render_hits(&hits, CODE_EVIDENCE_CAP)
481}
482
483pub fn gather_code_files(workspace: &str, extensions: &[String]) -> Vec<(String, String)> {
498 const MAX_FILES: usize = 400;
499 const MAX_BYTES: u64 = 200_000;
500 let mut out = Vec::new();
501 for entry in ignore::WalkBuilder::new(workspace).build().flatten() {
502 if out.len() >= MAX_FILES {
503 break;
504 }
505 let path = entry.path();
506 let ext = path.extension().and_then(|e| e.to_str());
507 if !ext.is_some_and(|e| extensions.iter().any(|x| x == e)) {
508 continue;
509 }
510 if path.metadata().map(|m| m.len()).unwrap_or(u64::MAX) > MAX_BYTES {
511 continue;
512 }
513 if let Ok(src) = std::fs::read_to_string(path) {
514 let rel = path
515 .strip_prefix(workspace)
516 .unwrap_or(path)
517 .to_string_lossy()
518 .to_string();
519 out.push((rel, src));
520 }
521 }
522 out
523}
524
525#[derive(Clone, Copy)]
531pub struct CodeSearch<'a> {
532 pub embedder: &'a dyn Embedder,
533 pub index: &'a dyn SemanticIndex,
534 pub top_k: usize,
535}
536
537pub fn code_search_tool_definition() -> serde_json::Value {
540 serde_json::json!({
541 "type": "function",
542 "function": {
543 "name": "code_search",
544 "description": "Search the indexed codebase for the most relevant code by MEANING \
545 (semantic/embedding search, not keyword) — use it to find where \
546 something is implemented when you don't have a file path, e.g. \
547 'where is the retry backoff computed'. Returns the top matching \
548 code chunks with their file:line; then read_file the ones you need.",
549 "parameters": {
550 "type": "object",
551 "properties": {
552 "query": { "type": "string", "description": "What to find, in natural language or a symbol name." }
553 },
554 "required": ["query"]
555 }
556 }
557 })
558}
559
560pub(crate) async fn execute_code_search(
563 args: &serde_json::Value,
564 search: CodeSearch<'_>,
565 color: bool,
566 tool_output_lines: usize,
567) -> String {
568 let query = args["query"].as_str().unwrap_or("").trim();
569 print_tool_call("code_search", query, color);
570 if query.is_empty() {
571 return "error: code_search requires a non-empty `query`".to_string();
572 }
573 let out = match retrieve_evidence(query, search.embedder, search.index, search.top_k).await {
574 Some(block) => block,
575 None => "no code matched — the semantic index may be empty or the embedding model \
576 unavailable; use read_file/find if you already know the path"
577 .to_string(),
578 };
579 print_tool_output(&out, tool_output_lines, color);
580 out
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use std::sync::atomic::{AtomicUsize, Ordering};
587 use std::sync::Arc;
588 use wiremock::matchers::{method, path};
589 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
590
591 #[test]
592 fn gather_code_files_honors_the_extension_allowlist_956() {
593 static N: AtomicUsize = AtomicUsize::new(0);
598 let dir = std::env::temp_dir().join(format!(
599 "newt-gcf-{}-{}",
600 std::process::id(),
601 N.fetch_add(1, Ordering::Relaxed)
602 ));
603 std::fs::create_dir_all(&dir).unwrap();
604 std::fs::write(dir.join("tool.sh"), "myfunc() { echo hi; }\n").unwrap();
605 std::fs::write(dir.join("main.rs"), "pub fn open() {}\n").unwrap();
606 let ws = dir.to_string_lossy().to_string();
607
608 let sh_only = gather_code_files(&ws, &["sh".to_string()]);
610 assert!(
611 sh_only.iter().any(|(p, _)| p.ends_with("tool.sh")),
612 "the .sh file must be gathered when `sh` is requested: {sh_only:?}"
613 );
614 assert!(
615 !sh_only.iter().any(|(p, _)| p.ends_with("main.rs")),
616 "rs was not requested: {sh_only:?}"
617 );
618
619 let both = gather_code_files(&ws, &["rs".to_string(), "sh".to_string()]);
621 assert!(both.iter().any(|(p, _)| p.ends_with("tool.sh")));
622 assert!(both.iter().any(|(p, _)| p.ends_with("main.rs")));
623
624 assert!(gather_code_files(&ws, &[]).is_empty());
626
627 let _ = std::fs::remove_dir_all(&dir);
628 }
629
630 #[tokio::test]
631 async fn embed_parses_the_vector() {
632 let server = MockServer::start().await;
633 Mock::given(method("POST"))
634 .and(path("/api/embeddings"))
635 .respond_with(
636 ResponseTemplate::new(200)
637 .set_body_json(serde_json::json!({ "embedding": [0.1, 0.2, 0.3] })),
638 )
639 .mount(&server)
640 .await;
641 let c = EmbeddingsClient::new(
642 server.uri(),
643 "nomic-embed-text",
644 crate::BackendKind::Ollama,
645 None,
646 30,
647 0,
648 );
649 assert_eq!(c.embed("hello").await.unwrap(), vec![0.1f32, 0.2, 0.3]);
650 }
651
652 #[tokio::test]
653 async fn embed_openai_protocol_hits_v1_and_parses_data() {
654 let server = MockServer::start().await;
657 Mock::given(method("POST"))
658 .and(path("/v1/embeddings"))
659 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
660 "data": [{ "embedding": [0.5, 0.6] }]
661 })))
662 .mount(&server)
663 .await;
664 let c = EmbeddingsClient::new(
665 server.uri(),
666 "bge-m3",
667 crate::BackendKind::Openai,
668 None,
669 30,
670 0,
671 );
672 assert_eq!(c.embed("hello").await.unwrap(), vec![0.5f32, 0.6]);
673 let reqs = server.received_requests().await.unwrap();
677 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
678 assert_eq!(body["input"], "hello");
679 assert!(body.get("prompt").is_none());
680 }
681
682 #[tokio::test]
685 async fn embed_batch_preserves_order() {
686 struct ByLen;
687 impl Respond for ByLen {
688 fn respond(&self, req: &Request) -> ResponseTemplate {
689 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
690 let n = body["prompt"].as_str().unwrap_or("").chars().count() as f64;
691 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "embedding": [n] }))
692 }
693 }
694 let server = MockServer::start().await;
695 Mock::given(method("POST"))
696 .and(path("/api/embeddings"))
697 .respond_with(ByLen)
698 .mount(&server)
699 .await;
700 let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
701 let out = c
702 .embed_batch(&["a".into(), "bbb".into(), "cc".into()])
703 .await
704 .unwrap();
705 assert_eq!(out, vec![vec![1.0f32], vec![3.0], vec![2.0]]);
706 }
707
708 #[tokio::test]
709 async fn embed_retries_then_succeeds() {
710 struct FailOnce(Arc<AtomicUsize>);
711 impl Respond for FailOnce {
712 fn respond(&self, _req: &Request) -> ResponseTemplate {
713 if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
714 ResponseTemplate::new(500)
715 } else {
716 ResponseTemplate::new(200)
717 .set_body_json(serde_json::json!({ "embedding": [1.0, 2.0] }))
718 }
719 }
720 }
721 let calls = Arc::new(AtomicUsize::new(0));
722 let server = MockServer::start().await;
723 Mock::given(method("POST"))
724 .and(path("/api/embeddings"))
725 .respond_with(FailOnce(calls.clone()))
726 .mount(&server)
727 .await;
728 let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 2);
729 assert_eq!(c.embed("x").await.unwrap(), vec![1.0f32, 2.0]);
730 assert_eq!(calls.load(Ordering::SeqCst), 2, "one failure, one success");
731 }
732
733 #[tokio::test]
734 async fn embed_gives_up_after_retries() {
735 let server = MockServer::start().await;
736 Mock::given(method("POST"))
737 .and(path("/api/embeddings"))
738 .respond_with(ResponseTemplate::new(500))
739 .mount(&server)
740 .await;
741 let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 1);
742 let err = c.embed("x").await.unwrap_err();
743 assert!(err.to_string().contains("returned 500"), "{err}");
744 }
745
746 #[tokio::test]
747 async fn embed_rejects_missing_and_empty_vector() {
748 let server = MockServer::start().await;
749 Mock::given(method("POST"))
751 .and(path("/api/embeddings"))
752 .respond_with(
753 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "nope": 1 })),
754 )
755 .mount(&server)
756 .await;
757 let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
758 assert!(c
759 .embed("x")
760 .await
761 .unwrap_err()
762 .to_string()
763 .contains("missing `embedding`"));
764 }
765
766 #[tokio::test]
769 async fn embed_retries_zero_makes_exactly_one_call() {
770 struct Count(Arc<AtomicUsize>);
771 impl Respond for Count {
772 fn respond(&self, _req: &Request) -> ResponseTemplate {
773 self.0.fetch_add(1, Ordering::SeqCst);
774 ResponseTemplate::new(500)
775 }
776 }
777 let calls = Arc::new(AtomicUsize::new(0));
778 let server = MockServer::start().await;
779 Mock::given(method("POST"))
780 .and(path("/api/embeddings"))
781 .respond_with(Count(calls.clone()))
782 .mount(&server)
783 .await;
784 let c = EmbeddingsClient::new(server.uri(), "m", crate::BackendKind::Ollama, None, 30, 0);
785 assert!(c.embed("x").await.is_err());
786 assert_eq!(
787 calls.load(Ordering::SeqCst),
788 1,
789 "retries=0 → exactly one attempt"
790 );
791 }
792
793 #[tokio::test]
796 async fn embed_sends_bearer_when_api_key_set() {
797 use wiremock::matchers::header;
798 let server = MockServer::start().await;
799 Mock::given(method("POST"))
800 .and(path("/api/embeddings"))
801 .and(header("authorization", "Bearer sk-test"))
802 .respond_with(
803 ResponseTemplate::new(200).set_body_json(serde_json::json!({ "embedding": [1.0] })),
804 )
805 .mount(&server)
806 .await;
807 let c = EmbeddingsClient::new(
808 server.uri(),
809 "m",
810 crate::BackendKind::Ollama,
811 Some("sk-test".into()),
812 30,
813 0,
814 );
815 assert_eq!(c.embed("x").await.unwrap(), vec![1.0f32]);
817 }
818
819 #[test]
822 fn chunk_rust_captures_defs_with_leading_doc() {
823 let src = "\
824//! file header
825use std::fmt;
826
827/// Adds two numbers.
828fn add(a: i32, b: i32) -> i32 {
829 a + b
830}
831
832/// A point.
833struct Point {
834 x: i32,
835}
836";
837 let chunks = chunk_source("src/lib.rs", src);
838 assert_eq!(chunks.len(), 2, "one chunk per def: {chunks:#?}");
839 assert_eq!(chunks[0].kind, "function");
841 assert_eq!(chunks[0].start_line, 4);
842 assert!(chunks[0].text.contains("/// Adds two numbers."));
843 assert!(chunks[0].text.contains("fn add"));
844 assert!(
845 !chunks[0].text.contains("struct Point"),
846 "def boundary respected"
847 );
848 assert_eq!(chunks[1].kind, "struct");
850 assert!(chunks[1].text.contains("/// A point.") && chunks[1].text.contains("struct Point"));
851 }
852
853 #[test]
854 fn chunk_python_def_and_class() {
855 let src = "\
856import os
857
858def greet(name):
859 return f\"hi {name}\"
860
861class Dog:
862 def bark(self):
863 return \"woof\"
864";
865 let chunks = chunk_source("app.py", src);
866 assert!(chunks
867 .iter()
868 .any(|c| c.kind == "function" && c.text.contains("def greet")));
869 assert!(chunks
870 .iter()
871 .any(|c| c.kind == "class" && c.text.contains("class Dog")));
872 }
873
874 #[test]
875 fn chunk_unknown_language_falls_back_to_windows() {
876 let src = (1..=90)
877 .map(|i| format!("line {i}"))
878 .collect::<Vec<_>>()
879 .join("\n");
880 let chunks = chunk_source("notes.txt", &src); assert!(chunks.iter().all(|c| c.kind == "window"));
882 assert!(
883 chunks.len() >= 2,
884 "90 lines / 40 ⇒ 3 windows: {}",
885 chunks.len()
886 );
887 assert_eq!(chunks.first().unwrap().start_line, 1);
889 assert_eq!(chunks.last().unwrap().end_line, 90);
890 }
891
892 #[test]
893 fn chunk_oversized_body_splits_into_windows() {
894 let body = (0..400)
896 .map(|i| format!(" let v{i} = {i};"))
897 .collect::<Vec<_>>()
898 .join("\n");
899 let src = format!("fn big() {{\n{body}\n}}\n");
900 let chunks = chunk_source("src/big.rs", &src);
901 assert!(chunks.len() > 1, "oversized body split: {}", chunks.len());
902 assert!(
903 chunks
904 .iter()
905 .all(|c| c.text.chars().count() <= CHUNK_MAX_CHARS + 200),
906 "every chunk stays bounded"
907 );
908 }
909
910 #[test]
911 fn chunk_empty_source_is_empty() {
912 assert!(chunk_source("src/lib.rs", "").is_empty());
913 }
914
915 fn chunk(file: &str, text: &str) -> CodeChunk {
918 CodeChunk {
919 file: file.into(),
920 start_line: 1,
921 end_line: 1,
922 kind: "function".into(),
923 text: text.into(),
924 }
925 }
926
927 #[test]
928 fn cosine_known_vectors() {
929 assert!(
930 (cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-6,
931 "identical"
932 );
933 assert!(
934 cosine(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-6,
935 "orthogonal → 0"
936 );
937 assert!(
938 (cosine(&[1.0, 0.0], &[-1.0, 0.0]) + 1.0).abs() < 1e-6,
939 "opposite → -1"
940 );
941 assert_eq!(cosine(&[1.0, 2.0, 3.0], &[1.0, 2.0]), 0.0);
943 assert_eq!(cosine(&[], &[]), 0.0);
944 assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), 0.0);
945 }
946
947 #[test]
948 fn index_search_orders_by_cosine_and_truncates() {
949 let idx = SessionSemanticIndex::default();
950 idx.index_chunk(chunk("a.rs", "alpha"), vec![1.0, 0.0]);
951 idx.index_chunk(chunk("b.rs", "beta"), vec![0.0, 1.0]);
952 idx.index_chunk(chunk("c.rs", "gamma"), vec![0.9, 0.1]);
953 assert_eq!(idx.chunks_indexed(), 3);
954 assert_eq!(idx.indexed_chars(), 5 + 4 + 5);
955 let hits = idx.search(&[1.0, 0.0], 2);
957 assert_eq!(hits.len(), 2, "top_k truncates");
958 assert_eq!(hits[0].1.file, "a.rs");
959 assert_eq!(hits[1].1.file, "c.rs");
960 assert!(hits[0].0 > hits[1].0, "descending score");
961 assert_eq!(idx.search(&[1.0, 0.0], 99).len(), 3);
963 let empty = SessionSemanticIndex::default();
965 assert!(empty.search(&[1.0, 0.0], 5).is_empty());
966 idx.clear();
968 assert_eq!(idx.chunks_indexed(), 0);
969 }
970
971 #[test]
972 fn code_evidence_block_none_when_empty_renders_and_caps() {
973 let idx = SessionSemanticIndex::default();
974 assert_eq!(build_code_evidence_block(&idx, &[1.0, 0.0], 5, 6_000), None);
976 idx.index_chunk(chunk("src/lib.rs", "fn add() {}"), vec![1.0, 0.0]);
977 let block = build_code_evidence_block(&idx, &[1.0, 0.0], 5, 6_000).unwrap();
978 assert!(block.starts_with("<code_evidence>\n") && block.ends_with("</code_evidence>"));
979 assert!(
980 block.contains("src/lib.rs:1-1"),
981 "file:line header: {block}"
982 );
983 assert!(block.contains("fn add() {}"));
984 let capped = build_code_evidence_block(&idx, &[1.0, 0.0], 5, 30).unwrap();
986 assert!(capped.contains("omitted to fit"), "{capped}");
987 }
988
989 struct MockEmbedder;
994 #[async_trait]
995 impl Embedder for MockEmbedder {
996 async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
997 Ok(vec![
998 text.matches('a').count() as f32,
999 text.matches('b').count() as f32,
1000 text.chars().count() as f32,
1001 ])
1002 }
1003 }
1004
1005 struct FailEmbedder;
1007 #[async_trait]
1008 impl Embedder for FailEmbedder {
1009 async fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
1010 anyhow::bail!("embeddings model not available")
1011 }
1012 }
1013
1014 struct CountingFailEmbedder(Arc<AtomicUsize>);
1017 #[async_trait]
1018 impl Embedder for CountingFailEmbedder {
1019 async fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
1020 self.0.fetch_add(1, Ordering::SeqCst);
1021 anyhow::bail!("embeddings endpoint returned 404")
1022 }
1023 }
1024
1025 #[tokio::test]
1026 async fn index_files_disable_stops_on_first_failure() {
1027 let files = vec![("a.rs".to_string(), "fn add() {}\nfn sub() {}".to_string())];
1030 let idx = SessionSemanticIndex::default();
1031 let calls = Arc::new(AtomicUsize::new(0));
1032 let n = index_files(
1033 &files,
1034 &CountingFailEmbedder(calls.clone()),
1035 &idx,
1036 crate::OnEmbedFailure::Disable,
1037 )
1038 .await;
1039 assert_eq!(n, 0);
1040 assert_eq!(idx.chunks_indexed(), 0);
1041 assert_eq!(
1042 calls.load(Ordering::SeqCst),
1043 1,
1044 "Disable must short-circuit after the first failure"
1045 );
1046
1047 let calls2 = Arc::new(AtomicUsize::new(0));
1049 index_files(
1050 &files,
1051 &CountingFailEmbedder(calls2.clone()),
1052 &SessionSemanticIndex::default(),
1053 crate::OnEmbedFailure::Warn,
1054 )
1055 .await;
1056 assert!(
1057 calls2.load(Ordering::SeqCst) >= 2,
1058 "Warn keeps trying per-chunk, got {}",
1059 calls2.load(Ordering::SeqCst)
1060 );
1061 }
1062
1063 #[tokio::test]
1064 async fn index_files_chunks_embeds_and_skips_failures() {
1065 let files = vec![("a.rs".to_string(), "fn add() {}\nfn sub() {}".to_string())];
1066 let idx = SessionSemanticIndex::default();
1068 let n = index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1069 assert_eq!(n, idx.chunks_indexed() as usize);
1070 assert!(n >= 2, "two fns indexed, got {n}");
1071 let empty = SessionSemanticIndex::default();
1074 assert_eq!(
1075 index_files(&files, &FailEmbedder, &empty, crate::OnEmbedFailure::Warn).await,
1076 0
1077 );
1078 assert_eq!(empty.chunks_indexed(), 0);
1079 }
1080
1081 #[tokio::test]
1082 async fn retrieve_evidence_embeds_query_ranks_and_degrades() {
1083 let files = vec![("a.rs".to_string(), "fn aaa() {}\nfn bbb() {}".to_string())];
1084 let idx = SessionSemanticIndex::default();
1085 index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1086 let block = retrieve_evidence("aaaaa", &MockEmbedder, &idx, 1)
1088 .await
1089 .unwrap();
1090 assert!(
1091 block.contains("<code_evidence>") && block.contains("aaa"),
1092 "{block}"
1093 );
1094 assert!(retrieve_evidence("x", &FailEmbedder, &idx, 1)
1096 .await
1097 .is_none());
1098 let empty = SessionSemanticIndex::default();
1100 assert!(retrieve_evidence("aaa", &MockEmbedder, &empty, 1)
1101 .await
1102 .is_none());
1103 }
1104
1105 #[tokio::test]
1106 async fn code_search_tool_embeds_searches_and_coaches() {
1107 let files = vec![("a.rs".to_string(), "fn aaa() {}\nfn bbb() {}".to_string())];
1108 let idx = SessionSemanticIndex::default();
1109 index_files(&files, &MockEmbedder, &idx, crate::OnEmbedFailure::Disable).await;
1110 let search = CodeSearch {
1111 embedder: &MockEmbedder,
1112 index: &idx,
1113 top_k: 1,
1114 };
1115 let out =
1117 execute_code_search(&serde_json::json!({"query": "aaaaa"}), search, false, 20).await;
1118 assert!(
1119 out.contains("<code_evidence>") && out.contains("aaa"),
1120 "{out}"
1121 );
1122 assert!(
1124 execute_code_search(&serde_json::json!({}), search, false, 20)
1125 .await
1126 .starts_with("error:")
1127 );
1128 let empty = SessionSemanticIndex::default();
1130 let s2 = CodeSearch {
1131 embedder: &MockEmbedder,
1132 index: &empty,
1133 top_k: 1,
1134 };
1135 assert!(
1136 execute_code_search(&serde_json::json!({"query": "x"}), s2, false, 20)
1137 .await
1138 .contains("no code matched")
1139 );
1140 }
1141
1142 #[test]
1143 fn code_search_tool_definition_shape() {
1144 let def = code_search_tool_definition();
1145 assert_eq!(def["function"]["name"], "code_search");
1146 assert!(def["function"]["parameters"]["properties"]["query"].is_object());
1147 }
1148
1149 #[test]
1150 fn rerank_boosts_defs_and_paths_and_stays_stable() {
1151 let c = |file: &str, kind: &str| CodeChunk {
1152 file: file.to_string(),
1153 start_line: 1,
1154 end_line: 2,
1155 kind: kind.to_string(),
1156 text: "x".to_string(),
1157 };
1158 let mut hits = vec![(0.5, c("a.rs", "window")), (0.5, c("b.rs", "function"))];
1160 rerank("anything", &mut hits);
1161 assert_eq!(hits[0].1.kind, "function", "def promoted over window");
1162 let mut hits = vec![
1164 (0.50, c("other.rs", "window")),
1165 (0.48, c("retry.rs", "window")),
1166 ];
1167 rerank("where is retry handled", &mut hits);
1168 assert_eq!(hits[0].1.file, "retry.rs", "path-term match promoted");
1169 let mut hits = vec![(0.90, c("x.rs", "window")), (0.50, c("y.rs", "function"))];
1171 rerank("anything", &mut hits);
1172 assert_eq!(
1173 hits[0].1.file, "x.rs",
1174 "strong cosine wins over a small boost"
1175 );
1176 let mut hits = vec![
1178 (0.9, c("a.rs", "window")),
1179 (0.6, c("b.rs", "window")),
1180 (0.4, c("c.rs", "window")),
1181 ];
1182 let before = hits.clone();
1183 rerank("zz", &mut hits); assert_eq!(hits, before, "no boost → cosine order unchanged");
1185 let mut hits = vec![
1187 (0.5, c("first.rs", "window")),
1188 (0.5, c("second.rs", "window")),
1189 ];
1190 rerank("zz", &mut hits);
1191 assert_eq!(hits[0].1.file, "first.rs", "stable: ties keep input order");
1192 }
1193}