1use super::{
4 CodebaseRpcServer, HealthResponse, HealthRpcServer, ProcessLocalRequest, ProcessLocalResponse,
5 ProcessRemoteRequest, ProcessRemoteResponse,
6};
7use anyhow::Result;
8use jsonrpsee::core::RpcResult;
9use std::path::Path;
10use std::time::{Instant, SystemTime};
11
12pub struct HealthRpcImpl;
14
15#[jsonrpsee::core::async_trait]
16impl HealthRpcServer for HealthRpcImpl {
17 async fn health_check(&self) -> RpcResult<HealthResponse> {
18 let timestamp = SystemTime::now()
19 .duration_since(SystemTime::UNIX_EPOCH)
20 .map_err(|e| {
21 jsonrpsee::types::ErrorObject::owned(-32000, "Timestamp error", Some(e.to_string()))
22 })?
23 .as_secs();
24
25 Ok(HealthResponse {
26 status: "healthy".to_string(),
27 timestamp,
28 version: env!("CARGO_PKG_VERSION").to_string(),
29 })
30 }
31}
32
33pub struct CodebaseRpcImpl {
35 cache: std::sync::Arc<crate::mcp_server::cache::McpCache>,
36}
37
38impl CodebaseRpcImpl {
39 pub fn new(cache: std::sync::Arc<crate::mcp_server::cache::McpCache>) -> Self {
40 Self { cache }
41 }
42}
43
44#[jsonrpsee::core::async_trait]
45impl CodebaseRpcServer for CodebaseRpcImpl {
46 async fn process_local_codebase(
47 &self,
48 request: ProcessLocalRequest,
49 ) -> RpcResult<ProcessLocalResponse> {
50 let start = Instant::now();
51
52 validate_path(&request.path)?;
54
55 let cache_key = crate::mcp_server::cache::ProcessLocalCacheKey::from_request(&request);
57 if let Some(cached) = self.cache.get_process_local(&cache_key).await {
58 let processing_time_ms = start.elapsed().as_millis() as u64;
59 return Ok(ProcessLocalResponse {
60 answer: cached.answer,
61 context: if request.include_context.unwrap_or(false) {
62 Some(cached.markdown)
63 } else {
64 None
65 },
66 file_count: cached.file_count,
67 token_count: cached.token_count,
68 processing_time_ms,
69 llm_tool: cached.llm_tool,
70 });
71 }
72
73 let cache = self.cache.clone();
75 let response = tokio::task::spawn_blocking(move || process_codebase_sync(request, start))
76 .await
77 .map_err(|e| {
78 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
79 })?
80 .map_err(|e| {
81 jsonrpsee::types::ErrorObject::owned(
82 -32603,
83 "Processing error",
84 Some(e.to_string()),
85 )
86 })?;
87
88 let cache_value = crate::mcp_server::cache::ProcessLocalCacheValue {
90 answer: response.answer.clone(),
91 markdown: response.context.clone().unwrap_or_default(),
92 file_count: response.file_count,
93 token_count: response.token_count,
94 llm_tool: response.llm_tool.clone(),
95 };
96 cache.set_process_local(cache_key, cache_value).await;
97
98 Ok(response)
99 }
100
101 async fn process_remote_repo(
102 &self,
103 request: ProcessRemoteRequest,
104 ) -> RpcResult<ProcessRemoteResponse> {
105 let start = Instant::now();
106
107 validate_url(&request.repo_url)?;
109
110 let response = tokio::task::spawn_blocking(move || process_remote_sync(request, start))
112 .await
113 .map_err(|e| {
114 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
115 })?
116 .map_err(|e| {
117 jsonrpsee::types::ErrorObject::owned(
118 -32603,
119 "Processing error",
120 Some(e.to_string()),
121 )
122 })?;
123
124 Ok(response)
125 }
126
127 async fn get_file_metadata(
128 &self,
129 request: super::GetFileMetadataRequest,
130 ) -> RpcResult<super::GetFileMetadataResponse> {
131 validate_path(&request.file_path)?;
133
134 let response = tokio::task::spawn_blocking(move || get_file_metadata_sync(request))
136 .await
137 .map_err(|e| {
138 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
139 })?
140 .map_err(|e| {
141 jsonrpsee::types::ErrorObject::owned(
142 -32603,
143 "Processing error",
144 Some(e.to_string()),
145 )
146 })?;
147
148 Ok(response)
149 }
150
151 async fn search_codebase(
152 &self,
153 request: super::SearchCodebaseRequest,
154 ) -> RpcResult<super::SearchCodebaseResponse> {
155 use std::time::Instant;
156 let start = Instant::now();
157
158 validate_path(&request.path)?;
160
161 let response = tokio::task::spawn_blocking(move || search_codebase_sync(request, start))
163 .await
164 .map_err(|e| {
165 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
166 })?
167 .map_err(|e| {
168 jsonrpsee::types::ErrorObject::owned(-32603, "Search error", Some(e.to_string()))
169 })?;
170
171 Ok(response)
172 }
173
174 async fn diff_files(
175 &self,
176 request: super::DiffFilesRequest,
177 ) -> RpcResult<super::DiffFilesResponse> {
178 validate_path(&request.file1_path)?;
180 validate_path(&request.file2_path)?;
181
182 let response = tokio::task::spawn_blocking(move || diff_files_sync(request))
184 .await
185 .map_err(|e| {
186 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
187 })?
188 .map_err(|e| {
189 jsonrpsee::types::ErrorObject::owned(-32603, "Diff error", Some(e.to_string()))
190 })?;
191
192 Ok(response)
193 }
194
195 async fn semantic_search(
196 &self,
197 request: super::SemanticSearchRequest,
198 ) -> RpcResult<super::SemanticSearchResponse> {
199 use std::time::Instant;
200 let start = Instant::now();
201
202 validate_path(&request.path)?;
204
205 let response = tokio::task::spawn_blocking(move || semantic_search_sync(request, start))
207 .await
208 .map_err(|e| {
209 jsonrpsee::types::ErrorObject::owned(-32603, "Internal error", Some(e.to_string()))
210 })?
211 .map_err(|e| {
212 jsonrpsee::types::ErrorObject::owned(
213 -32603,
214 "Semantic search error",
215 Some(e.to_string()),
216 )
217 })?;
218
219 Ok(response)
220 }
221}
222
223pub(super) fn validate_path(path: &Path) -> RpcResult<()> {
225 let path_str = path.to_string_lossy();
227 if path_str.contains("..") || path_str.contains('~') {
228 return Err(jsonrpsee::types::ErrorObject::owned(
229 -32602,
230 "Invalid path: potential security risk",
231 None::<String>,
232 ));
233 }
234
235 if !path.is_absolute() && !path.starts_with(".") && !path.exists() {
237 return Err(jsonrpsee::types::ErrorObject::owned(
238 -32602,
239 "Invalid path: must be absolute or relative to current directory",
240 None::<String>,
241 ));
242 }
243
244 Ok(())
245}
246
247fn validate_url(url: &str) -> RpcResult<()> {
249 if !url.starts_with("https://") && !url.starts_with("http://") {
251 return Err(jsonrpsee::types::ErrorObject::owned(
252 -32602,
253 "Invalid URL: must start with http:// or https://",
254 None::<String>,
255 ));
256 }
257
258 if url.contains("github.com") && !url.contains("/") {
260 return Err(jsonrpsee::types::ErrorObject::owned(
261 -32602,
262 "Invalid GitHub URL format",
263 None::<String>,
264 ));
265 }
266
267 Ok(())
268}
269
270pub(super) fn process_codebase_sync(
272 request: ProcessLocalRequest,
273 start: Instant,
274) -> Result<ProcessLocalResponse> {
275 use crate::cli::{Config, LlmTool};
276 use crate::core::cache::FileCache;
277 use crate::core::context_builder::{generate_markdown, ContextOptions};
278 use crate::core::prioritizer::prioritize_files;
279 use crate::core::token::TokenCounter;
280 use crate::core::walker::{walk_directory, WalkOptions};
281 use std::sync::Arc;
282
283 let llm_tool = if let Some(tool_str) = &request.llm_tool {
285 match tool_str.as_str() {
286 "gemini" => LlmTool::Gemini,
287 "codex" => LlmTool::Codex,
288 _ => LlmTool::Gemini,
289 }
290 } else {
291 LlmTool::Gemini
292 };
293
294 let effective_max_tokens = if let Some(max_tokens) = request.max_tokens {
296 max_tokens as usize
297 } else {
298 llm_tool.default_max_tokens()
300 };
301
302 let prompt_tokens = if let Ok(counter) = TokenCounter::new() {
304 counter
305 .count_tokens(&request.prompt)
306 .unwrap_or(request.prompt.len() / 4)
307 } else {
308 request.prompt.len() / 4 };
310
311 let safety_buffer = 1000; let context_tokens = effective_max_tokens.saturating_sub(prompt_tokens + safety_buffer);
313
314 let config = Config {
316 paths: Some(vec![request.path.clone()]),
317 include: if request.include_patterns.is_empty() {
318 None
319 } else {
320 Some(request.include_patterns.clone())
321 },
322 ignore: if request.ignore_patterns.is_empty() {
323 None
324 } else {
325 Some(request.ignore_patterns.clone())
326 },
327 trace_imports: request.include_imports,
328 max_tokens: Some(context_tokens),
329 llm_tool,
330 prompt: Some(request.prompt.clone()),
332 output_file: None,
334 copy: false,
335 verbose: 0,
336 quiet: true,
337 ..Default::default()
338 };
339
340 let walk_options = WalkOptions::from_config(&config)?;
342
343 let context_options = ContextOptions::from_config(&config)?;
345
346 let cache = Arc::new(FileCache::new());
348
349 let files = walk_directory(&request.path, walk_options)?;
351
352 let prioritized_files = if context_options.max_tokens.is_some() {
354 prioritize_files(files, &context_options, cache.clone())?
355 } else {
356 files
357 };
358
359 let output = generate_markdown(prioritized_files, context_options, cache)?;
361
362 let token_counter = crate::core::token::TokenCounter::new()?;
364 let token_count = token_counter.count_tokens(&output)?;
365
366 let file_count = output
368 .lines()
369 .filter(|line| {
370 line.starts_with("## ")
371 && !line.starts_with("## Table of")
372 && !line.starts_with("## Statistics")
373 && !line.starts_with("## File Structure")
374 })
375 .count();
376
377 let answer = execute_llm_sync(&request.prompt, &output, request.llm_tool.as_deref())?;
379
380 let processing_time_ms = start.elapsed().as_millis() as u64;
381
382 Ok(ProcessLocalResponse {
383 answer,
384 context: if request.include_context.unwrap_or(false) {
385 Some(output)
386 } else {
387 None
388 },
389 file_count,
390 token_count,
391 processing_time_ms,
392 llm_tool: request.llm_tool.unwrap_or_else(|| "gemini".to_string()),
393 })
394}
395
396pub(super) fn process_remote_sync(
398 request: ProcessRemoteRequest,
399 start: Instant,
400) -> Result<ProcessRemoteResponse> {
401 use crate::cli::Config;
402 use crate::core::cache::FileCache;
403 use crate::core::context_builder::{generate_markdown, ContextOptions};
404 use crate::core::prioritizer::prioritize_files;
405 use crate::core::walker::{walk_directory, WalkOptions};
406 use crate::remote;
407 use std::sync::Arc;
408
409 let temp_dir = remote::fetch_repository(&request.repo_url, false)?;
411 let repo_path = remote::get_repo_path(&temp_dir, &request.repo_url)?;
412
413 let repo_name = request
415 .repo_url
416 .split('/')
417 .next_back()
418 .unwrap_or("unknown")
419 .trim_end_matches(".git")
420 .to_string();
421
422 let llm_tool = if let Some(tool_str) = &request.llm_tool {
424 match tool_str.as_str() {
425 "gemini" => crate::cli::LlmTool::Gemini,
426 "codex" => crate::cli::LlmTool::Codex,
427 _ => crate::cli::LlmTool::Gemini,
428 }
429 } else {
430 crate::cli::LlmTool::Gemini
431 };
432
433 let effective_max_tokens = if let Some(max_tokens) = request.max_tokens {
435 max_tokens as usize
436 } else {
437 llm_tool.default_max_tokens()
438 };
439
440 let prompt_tokens = if let Ok(counter) = crate::core::token::TokenCounter::new() {
442 counter
443 .count_tokens(&request.prompt)
444 .unwrap_or(request.prompt.len() / 4)
445 } else {
446 request.prompt.len() / 4
447 };
448
449 let safety_buffer = 1000;
450 let context_tokens = effective_max_tokens.saturating_sub(prompt_tokens + safety_buffer);
451
452 let config = Config {
454 paths: Some(vec![repo_path.clone()]),
455 include: if request.include_patterns.is_empty() {
456 None
457 } else {
458 Some(request.include_patterns.clone())
459 },
460 ignore: if request.ignore_patterns.is_empty() {
461 None
462 } else {
463 Some(request.ignore_patterns.clone())
464 },
465 trace_imports: request.include_imports,
466 max_tokens: Some(context_tokens),
467 llm_tool,
468 prompt: Some(request.prompt.clone()),
469 output_file: None,
471 copy: false,
472 verbose: 0,
473 quiet: true,
474 ..Default::default()
475 };
476
477 let walk_options = WalkOptions::from_config(&config)?;
479
480 let context_options = ContextOptions::from_config(&config)?;
482
483 let cache = Arc::new(FileCache::new());
485
486 let files = walk_directory(&repo_path, walk_options)?;
488
489 let prioritized_files = if context_options.max_tokens.is_some() {
491 prioritize_files(files, &context_options, cache.clone())?
492 } else {
493 files
494 };
495
496 let output = generate_markdown(prioritized_files, context_options, cache)?;
498
499 let token_counter = crate::core::token::TokenCounter::new()?;
501 let token_count = token_counter.count_tokens(&output)?;
502
503 let file_count = output
505 .lines()
506 .filter(|line| {
507 line.starts_with("## ")
508 && !line.starts_with("## Table of")
509 && !line.starts_with("## Statistics")
510 && !line.starts_with("## File Structure")
511 })
512 .count();
513
514 let answer = execute_llm_sync(&request.prompt, &output, request.llm_tool.as_deref())?;
516
517 let processing_time_ms = start.elapsed().as_millis() as u64;
518
519 Ok(ProcessRemoteResponse {
520 answer,
521 context: if request.include_context.unwrap_or(false) {
522 Some(output)
523 } else {
524 None
525 },
526 file_count,
527 token_count,
528 processing_time_ms,
529 repo_name,
530 llm_tool: request.llm_tool.unwrap_or_else(|| "gemini".to_string()),
531 })
532}
533
534pub(super) fn get_file_metadata_sync(
536 request: super::GetFileMetadataRequest,
537) -> Result<super::GetFileMetadataResponse> {
538 use std::fs;
539 use std::time::SystemTime;
540
541 if !request.file_path.exists() {
543 return Err(anyhow::anyhow!(
544 "File not found: {}",
545 request.file_path.display()
546 ));
547 }
548
549 let metadata = fs::metadata(&request.file_path)?;
551
552 let modified = metadata
554 .modified()?
555 .duration_since(SystemTime::UNIX_EPOCH)?
556 .as_secs();
557
558 let symlink_metadata = fs::symlink_metadata(&request.file_path)?;
560 let is_symlink = symlink_metadata.is_symlink();
561
562 let language = request
564 .file_path
565 .extension()
566 .and_then(|ext| ext.to_str())
567 .and_then(|ext| match ext {
568 "rs" => Some("rust"),
569 "py" => Some("python"),
570 "js" | "jsx" => Some("javascript"),
571 "ts" | "tsx" => Some("typescript"),
572 "go" => Some("go"),
573 "java" => Some("java"),
574 "c" => Some("c"),
575 "cpp" | "cc" | "cxx" => Some("cpp"),
576 "h" | "hpp" => Some("cpp"),
577 "cs" => Some("csharp"),
578 "rb" => Some("ruby"),
579 "php" => Some("php"),
580 "swift" => Some("swift"),
581 "kt" | "kts" => Some("kotlin"),
582 "scala" => Some("scala"),
583 "r" => Some("r"),
584 "lua" => Some("lua"),
585 "dart" => Some("dart"),
586 "jl" => Some("julia"),
587 "hs" => Some("haskell"),
588 "elm" => Some("elm"),
589 "clj" | "cljs" => Some("clojure"),
590 "ex" | "exs" => Some("elixir"),
591 "ml" | "mli" => Some("ocaml"),
592 "nim" => Some("nim"),
593 "zig" => Some("zig"),
594 _ => None,
595 })
596 .map(String::from);
597
598 Ok(super::GetFileMetadataResponse {
599 path: request.file_path,
600 size: metadata.len(),
601 modified,
602 is_symlink,
603 language,
604 })
605}
606
607pub(super) fn search_codebase_sync(
609 request: super::SearchCodebaseRequest,
610 start: std::time::Instant,
611) -> Result<super::SearchCodebaseResponse> {
612 use std::fs;
613 use std::io::{BufRead, BufReader};
614 use walkdir::WalkDir;
615
616 let mut results = Vec::new();
617 let mut total_matches = 0;
618 let mut files_searched = 0;
619
620 let walker = WalkDir::new(&request.path)
622 .into_iter()
623 .filter_map(|e| e.ok())
624 .filter(|e| e.file_type().is_file());
625
626 for entry in walker {
627 let path = entry.path();
628
629 if let Some(ref pattern) = request.file_pattern {
631 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
632
633 if pattern.starts_with("*.") {
635 let extension = pattern.trim_start_matches("*.");
636 if !file_name.ends_with(extension) {
637 continue;
638 }
639 } else if !file_name.contains(pattern) {
640 continue;
641 }
642 }
643
644 if is_binary_file(path) {
646 continue;
647 }
648
649 files_searched += 1;
650
651 if let Ok(file) = fs::File::open(path) {
653 let reader = BufReader::new(file);
654
655 for (line_number, line_result) in reader.lines().enumerate() {
656 if let Ok(line) = line_result {
657 if line.to_lowercase().contains(&request.query.to_lowercase()) {
658 total_matches += 1;
659
660 let result = super::SearchResult {
662 file_path: path.to_path_buf(),
663 line_number: line_number + 1, line_content: line.clone(),
665 match_context: get_match_context(&line, &request.query),
666 };
667
668 results.push(result);
669
670 if let Some(max) = request.max_results {
672 if results.len() >= max as usize {
673 break;
674 }
675 }
676 }
677 }
678 }
679
680 if let Some(max) = request.max_results {
682 if results.len() >= max as usize {
683 break;
684 }
685 }
686 }
687 }
688
689 let search_time_ms = start.elapsed().as_millis() as u64;
690
691 Ok(super::SearchCodebaseResponse {
692 results,
693 total_matches,
694 files_searched,
695 search_time_ms,
696 })
697}
698
699fn is_binary_file(path: &Path) -> bool {
701 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
703 if matches!(
704 ext,
705 "exe"
706 | "dll"
707 | "so"
708 | "dylib"
709 | "bin"
710 | "obj"
711 | "o"
712 | "jpg"
713 | "jpeg"
714 | "png"
715 | "gif"
716 | "bmp"
717 | "ico"
718 | "webp"
719 | "mp3"
720 | "mp4"
721 | "avi"
722 | "mov"
723 | "wav"
724 | "flac"
725 | "zip"
726 | "tar"
727 | "gz"
728 | "bz2"
729 | "7z"
730 | "rar"
731 | "pdf"
732 | "doc"
733 | "docx"
734 | "xls"
735 | "xlsx"
736 | "ppt"
737 | "pptx"
738 ) {
739 return true;
740 }
741 }
742
743 if let Ok(mut file) = std::fs::File::open(path) {
745 use std::io::Read;
746 let mut buffer = [0; 512];
747 if let Ok(n) = file.read(&mut buffer) {
748 return buffer[..n].contains(&0);
750 }
751 }
752
753 false
754}
755
756fn get_match_context(line: &str, query: &str) -> String {
758 if let Some(pos) = line.to_lowercase().find(&query.to_lowercase()) {
759 let start = pos.saturating_sub(20);
760 let end = (pos + query.len() + 20).min(line.len());
761
762 let mut context = String::new();
763 if start > 0 {
764 context.push_str("...");
765 }
766 context.push_str(&line[start..end]);
767 if end < line.len() {
768 context.push_str("...");
769 }
770 context
771 } else {
772 line.to_string()
773 }
774}
775
776pub(super) fn diff_files_sync(
778 request: super::DiffFilesRequest,
779) -> Result<super::DiffFilesResponse> {
780 use std::fs;
781
782 if !request.file1_path.exists() {
784 return Err(anyhow::anyhow!(
785 "File not found: {}",
786 request.file1_path.display()
787 ));
788 }
789 if !request.file2_path.exists() {
790 return Err(anyhow::anyhow!(
791 "File not found: {}",
792 request.file2_path.display()
793 ));
794 }
795
796 let content1 = fs::read(&request.file1_path)?;
798 let content2 = fs::read(&request.file2_path)?;
799
800 let is_binary = content1.contains(&0) || content2.contains(&0);
802
803 if is_binary {
804 return Ok(super::DiffFilesResponse {
805 file1_path: request.file1_path,
806 file2_path: request.file2_path,
807 hunks: vec![],
808 added_lines: 0,
809 removed_lines: 0,
810 is_binary: true,
811 });
812 }
813
814 let text1 = String::from_utf8_lossy(&content1);
816 let text2 = String::from_utf8_lossy(&content2);
817
818 let lines1: Vec<&str> = text1.lines().collect();
820 let lines2: Vec<&str> = text2.lines().collect();
821
822 let hunks = compute_diff(&lines1, &lines2, request.context_lines.unwrap_or(3));
824
825 let mut added_lines = 0;
827 let mut removed_lines = 0;
828 for hunk in &hunks {
829 for line in hunk.content.lines() {
830 if line.starts_with('+') && !line.starts_with("+++") {
831 added_lines += 1;
832 } else if line.starts_with('-') && !line.starts_with("---") {
833 removed_lines += 1;
834 }
835 }
836 }
837
838 Ok(super::DiffFilesResponse {
839 file1_path: request.file1_path,
840 file2_path: request.file2_path,
841 hunks,
842 added_lines,
843 removed_lines,
844 is_binary: false,
845 })
846}
847
848fn compute_diff(lines1: &[&str], lines2: &[&str], context_lines: u32) -> Vec<super::DiffHunk> {
850 use std::cmp::min;
851
852 let mut hunks = Vec::new();
853 let mut i = 0;
854 let mut j = 0;
855
856 while i < lines1.len() || j < lines2.len() {
857 while i < lines1.len() && j < lines2.len() && lines1[i] == lines2[j] {
859 i += 1;
860 j += 1;
861 }
862
863 if i >= lines1.len() && j >= lines2.len() {
864 break;
865 }
866
867 let hunk_start_i = i.saturating_sub(context_lines as usize);
869 let hunk_start_j = j.saturating_sub(context_lines as usize);
870 let mut hunk_content = String::new();
871
872 for k in hunk_start_i..i {
874 if k < lines1.len() {
875 hunk_content.push_str(&format!(" {}\n", lines1[k]));
876 }
877 }
878
879 let mut end_i = i;
881 let mut end_j = j;
882
883 while end_i < lines1.len() || end_j < lines2.len() {
885 let mut found_match = false;
886
887 if end_i < lines1.len() && end_j < lines2.len() && lines1[end_i] == lines2[end_j] {
889 found_match = true;
890 }
891
892 if found_match {
893 let mut k = 0;
895 while k < context_lines as usize
896 && end_i + k < lines1.len()
897 && end_j + k < lines2.len()
898 && lines1[end_i + k] == lines2[end_j + k]
899 {
900 k += 1;
901 }
902
903 if k >= context_lines as usize {
904 break;
905 }
906 }
907
908 if end_i < lines1.len()
910 && (end_j >= lines2.len()
911 || (end_i < lines1.len()
912 && end_j < lines2.len()
913 && lines1[end_i] != lines2[end_j]))
914 {
915 hunk_content.push_str(&format!("-{}\n", lines1[end_i]));
916 end_i += 1;
917 }
918
919 if end_j < lines2.len()
921 && (end_i >= lines1.len()
922 || (end_i < lines1.len()
923 && end_j < lines2.len()
924 && lines1[end_i - 1] != lines2[end_j]))
925 {
926 hunk_content.push_str(&format!("+{}\n", lines2[end_j]));
927 end_j += 1;
928 }
929 }
930
931 let context_end_i = min(end_i + context_lines as usize, lines1.len());
933 let context_end_j = min(end_j + context_lines as usize, lines2.len());
934
935 for k in end_i..context_end_i {
936 if k < lines1.len() && k - end_i < context_end_j - end_j {
937 hunk_content.push_str(&format!(" {}\n", lines1[k]));
938 }
939 }
940
941 let hunk = super::DiffHunk {
943 old_start: hunk_start_i + 1, old_lines: end_i - hunk_start_i,
945 new_start: hunk_start_j + 1, new_lines: end_j - hunk_start_j,
947 content: hunk_content,
948 };
949
950 hunks.push(hunk);
951
952 i = end_i;
954 j = end_j;
955 }
956
957 hunks
958}
959
960pub(super) fn semantic_search_sync(
962 request: super::SemanticSearchRequest,
963 start: std::time::Instant,
964) -> Result<super::SemanticSearchResponse> {
965 use crate::core::semantic::{get_analyzer_for_file, SemanticContext};
966 use std::fs;
967 use walkdir::WalkDir;
968
969 let mut results = Vec::new();
970 let mut total_matches = 0;
971 let mut files_analyzed = 0;
972
973 let walker = WalkDir::new(&request.path)
975 .into_iter()
976 .filter_map(|e| e.ok())
977 .filter(|e| e.file_type().is_file());
978
979 for entry in walker {
980 let path = entry.path();
981
982 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
984 if !is_supported_language_file(ext) {
985 continue;
986 }
987
988 let analyzer = match get_analyzer_for_file(path)? {
990 Some(analyzer) => analyzer,
991 None => continue, };
993
994 let content = match fs::read_to_string(path) {
996 Ok(content) => content,
997 Err(_) => continue,
998 };
999
1000 files_analyzed += 1;
1001
1002 let context = SemanticContext::new(
1004 path.to_path_buf(),
1005 request.path.clone(),
1006 3, );
1008
1009 if let Ok(analysis) = analyzer.analyze_file(path, &content, &context) {
1011 match request.search_type {
1012 super::SemanticSearchType::Functions => {
1013 for func in &analysis.exported_functions {
1015 if func
1016 .name
1017 .to_lowercase()
1018 .contains(&request.query.to_lowercase())
1019 {
1020 total_matches += 1;
1021
1022 let result = super::SemanticSearchResult {
1023 file_path: path.to_path_buf(),
1024 symbol_name: func.name.clone(),
1025 symbol_type: "function".to_string(),
1026 line_number: func.line + 1, context: format!(
1028 "{}function {}",
1029 if func.is_exported { "pub " } else { "" },
1030 func.name
1031 ),
1032 };
1033
1034 results.push(result);
1035
1036 if let Some(max) = request.max_results {
1037 if results.len() >= max as usize {
1038 break;
1039 }
1040 }
1041 }
1042 }
1043 }
1044 super::SemanticSearchType::Types => {
1045 for typ in &analysis.type_references {
1049 if typ
1050 .name
1051 .to_lowercase()
1052 .contains(&request.query.to_lowercase())
1053 {
1054 total_matches += 1;
1055
1056 let result = super::SemanticSearchResult {
1057 file_path: path.to_path_buf(),
1058 symbol_name: typ.name.clone(),
1059 symbol_type: "type".to_string(),
1060 line_number: typ.line + 1,
1061 context: format!("Type reference: {}", typ.name),
1062 };
1063
1064 results.push(result);
1065
1066 if let Some(max) = request.max_results {
1067 if results.len() >= max as usize {
1068 break;
1069 }
1070 }
1071 }
1072 }
1073 }
1074 super::SemanticSearchType::Imports => {
1075 for import in &analysis.imports {
1077 if import
1078 .module
1079 .to_lowercase()
1080 .contains(&request.query.to_lowercase())
1081 || import.items.iter().any(|item| {
1082 item.to_lowercase().contains(&request.query.to_lowercase())
1083 })
1084 {
1085 total_matches += 1;
1086
1087 let context = if import.items.is_empty() {
1088 format!("import {}", import.module)
1089 } else {
1090 format!(
1091 "import {{ {} }} from {}",
1092 import.items.join(", "),
1093 import.module
1094 )
1095 };
1096
1097 let result = super::SemanticSearchResult {
1098 file_path: path.to_path_buf(),
1099 symbol_name: import.module.clone(),
1100 symbol_type: "import".to_string(),
1101 line_number: import.line + 1,
1102 context,
1103 };
1104
1105 results.push(result);
1106
1107 if let Some(max) = request.max_results {
1108 if results.len() >= max as usize {
1109 break;
1110 }
1111 }
1112 }
1113 }
1114 }
1115 super::SemanticSearchType::References => {
1116 for call in &analysis.function_calls {
1118 if call
1119 .name
1120 .to_lowercase()
1121 .contains(&request.query.to_lowercase())
1122 {
1123 total_matches += 1;
1124
1125 let result = super::SemanticSearchResult {
1126 file_path: path.to_path_buf(),
1127 symbol_name: call.name.clone(),
1128 symbol_type: "reference".to_string(),
1129 line_number: call.line + 1,
1130 context: format!("Function call: {}", call.name),
1131 };
1132
1133 results.push(result);
1134
1135 if let Some(max) = request.max_results {
1136 if results.len() >= max as usize {
1137 break;
1138 }
1139 }
1140 }
1141 }
1142
1143 for typ in &analysis.type_references {
1145 if typ
1146 .name
1147 .to_lowercase()
1148 .contains(&request.query.to_lowercase())
1149 {
1150 total_matches += 1;
1151
1152 let result = super::SemanticSearchResult {
1153 file_path: path.to_path_buf(),
1154 symbol_name: typ.name.clone(),
1155 symbol_type: "reference".to_string(),
1156 line_number: typ.line + 1,
1157 context: format!("Type usage: {}", typ.name),
1158 };
1159
1160 results.push(result);
1161
1162 if let Some(max) = request.max_results {
1163 if results.len() >= max as usize {
1164 break;
1165 }
1166 }
1167 }
1168 }
1169 }
1170 }
1171 }
1172
1173 if let Some(max) = request.max_results {
1175 if results.len() >= max as usize {
1176 break;
1177 }
1178 }
1179 }
1180
1181 let search_time_ms = start.elapsed().as_millis() as u64;
1182
1183 Ok(super::SemanticSearchResponse {
1184 results,
1185 total_matches,
1186 files_analyzed,
1187 search_time_ms,
1188 })
1189}
1190
1191fn is_supported_language_file(ext: &str) -> bool {
1193 matches!(
1194 ext,
1195 "rs" | "py"
1196 | "js"
1197 | "jsx"
1198 | "ts"
1199 | "tsx"
1200 | "go"
1201 | "java"
1202 | "c"
1203 | "cpp"
1204 | "cc"
1205 | "cxx"
1206 | "h"
1207 | "hpp"
1208 | "cs"
1209 | "rb"
1210 | "php"
1211 | "swift"
1212 | "kt"
1213 | "kts"
1214 | "scala"
1215 | "r"
1216 | "lua"
1217 | "dart"
1218 | "jl"
1219 | "hs"
1220 | "elm"
1221 | "clj"
1222 | "cljs"
1223 | "ex"
1224 | "exs"
1225 | "ml"
1226 | "mli"
1227 | "nim"
1228 | "zig"
1229 )
1230}
1231
1232fn execute_llm_sync(prompt: &str, context: &str, llm_tool: Option<&str>) -> Result<String> {
1234 use crate::cli::LlmTool;
1235 use crate::utils::error::ContextCreatorError;
1236 use std::io::Write;
1237 use std::process::{Command, Stdio};
1238
1239 let tool = if let Some(tool_str) = llm_tool {
1241 match tool_str {
1242 "gemini" => LlmTool::Gemini,
1243 "codex" => LlmTool::Codex,
1244 _ => LlmTool::Gemini, }
1246 } else {
1247 LlmTool::Gemini };
1249
1250 let full_input = format!("{prompt}\n\n{context}");
1251 let tool_command = tool.command();
1252
1253 let mut child = Command::new(tool_command)
1254 .stdin(Stdio::piped())
1255 .stdout(Stdio::piped())
1256 .stderr(Stdio::piped())
1257 .spawn()
1258 .map_err(|e| {
1259 if e.kind() == std::io::ErrorKind::NotFound {
1260 ContextCreatorError::LlmToolNotFound {
1261 tool: tool_command.to_string(),
1262 install_instructions: tool.install_instructions().to_string(),
1263 }
1264 } else {
1265 ContextCreatorError::SubprocessError(e.to_string())
1266 }
1267 })?;
1268
1269 if let Some(mut stdin) = child.stdin.take() {
1271 stdin.write_all(full_input.as_bytes())?;
1272 stdin.flush()?;
1273 }
1274
1275 let output = child.wait_with_output()?;
1277
1278 if !output.status.success() {
1279 let stderr = String::from_utf8_lossy(&output.stderr);
1280 return Err(ContextCreatorError::SubprocessError(format!(
1281 "{tool_command} failed: {stderr}"
1282 ))
1283 .into());
1284 }
1285
1286 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1287}