Skip to main content

vtcode_core/tools/
file_search_rpc.rs

1//! RPC endpoint for file search operations
2//!
3//! Provides JSON-RPC interface to file_search_bridge for remote clients (VS Code extension).
4//! Handles request/response serialization and validation.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::path::PathBuf;
10
11use super::file_search_bridge::{self, FileMatchType, FileSearchConfig};
12
13/// JSON-RPC request for searching files
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SearchFilesRequest {
16    /// Fuzzy search pattern (e.g., "main", "test.rs")
17    pub pattern: String,
18    /// Root directory to search
19    pub workspace_root: PathBuf,
20    /// Maximum number of results to return
21    pub max_results: usize,
22    /// Patterns to exclude from results (glob-style)
23    pub exclude_patterns: Vec<String>,
24    /// Whether to respect .gitignore files
25    pub respect_gitignore: bool,
26}
27
28/// JSON-RPC request for listing files
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ListFilesRequest {
31    /// Root directory to list files from
32    pub workspace_root: PathBuf,
33    /// Patterns to exclude from results
34    pub exclude_patterns: Vec<String>,
35    /// Whether to respect .gitignore files
36    pub respect_gitignore: bool,
37    /// Maximum number of files to return
38    pub max_results: usize,
39}
40
41/// File match in RPC response
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FileMatchRpc {
44    /// Path relative to workspace root
45    pub path: String,
46    /// Whether the match is a file or directory
47    pub match_type: FileMatchType,
48    /// Fuzzy match score (higher = better match)
49    pub score: u32,
50    /// Character indices for match highlighting (optional)
51    pub indices: Option<Vec<u32>>,
52}
53
54/// JSON-RPC response for search_files
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SearchFilesResponse {
57    /// Matched files
58    pub matches: Vec<FileMatchRpc>,
59    /// Total number of matches found
60    pub total_match_count: usize,
61    /// Whether result was truncated
62    pub truncated: bool,
63}
64
65/// JSON-RPC response for list_files
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ListFilesResponse {
68    /// All discovered files
69    pub files: Vec<String>,
70    /// Total files found
71    pub total: usize,
72}
73
74/// Error response for RPC calls
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct RpcError {
77    /// Error code
78    pub code: i32,
79    /// Error message
80    pub message: String,
81    /// Additional error data
82    pub data: Option<Value>,
83}
84
85impl RpcError {
86    /// Create a new RPC error
87    pub fn new(code: i32, message: impl Into<String>) -> Self {
88        Self {
89            code,
90            message: message.into(),
91            data: None,
92        }
93    }
94
95    /// Invalid request error (-32600)
96    pub fn invalid_request(message: impl Into<String>) -> Self {
97        Self::new(-32600, message)
98    }
99
100    /// Method not found error (-32601)
101    pub fn method_not_found() -> Self {
102        Self::new(-32601, "Method not found")
103    }
104
105    /// Invalid params error (-32602)
106    pub fn invalid_params(message: impl Into<String>) -> Self {
107        Self::new(-32602, message)
108    }
109
110    /// Internal error (-32603)
111    pub fn internal_error(message: impl Into<String>) -> Self {
112        Self::new(-32603, message)
113    }
114
115    /// Custom error code
116    pub fn custom(code: i32, message: impl Into<String>) -> Self {
117        Self::new(code, message)
118    }
119}
120
121/// RPC request envelope
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RpcRequest {
124    /// JSON-RPC version (always "2.0")
125    pub jsonrpc: String,
126    /// RPC method name
127    pub method: String,
128    /// RPC method parameters (varies by method)
129    pub params: Value,
130    /// Request ID (for responses)
131    pub id: Option<Value>,
132}
133
134/// RPC response envelope
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct RpcResponse {
137    /// JSON-RPC version
138    pub jsonrpc: String,
139    /// Response ID (matches request ID)
140    pub id: Option<Value>,
141    /// Success result (if successful)
142    pub result: Option<Value>,
143    /// Error response (if failed)
144    pub error: Option<RpcError>,
145}
146
147impl RpcResponse {
148    /// Create a successful response
149    pub fn success(id: Option<Value>, result: Value) -> Self {
150        Self {
151            jsonrpc: "2.0".to_string(),
152            id,
153            result: Some(result),
154            error: None,
155        }
156    }
157
158    /// Create an error response
159    pub fn error(id: Option<Value>, error: RpcError) -> Self {
160        Self {
161            jsonrpc: "2.0".to_string(),
162            id,
163            result: None,
164            error: Some(error),
165        }
166    }
167}
168
169/// Handler for file search RPC requests
170pub struct FileSearchRpcHandler;
171
172impl FileSearchRpcHandler {
173    /// Handle an incoming RPC request
174    ///
175    /// # Arguments
176    ///
177    /// * `request` - Parsed JSON-RPC request
178    ///
179    /// # Returns
180    ///
181    /// JSON-RPC response with result or error
182    pub async fn handle_request(request: RpcRequest) -> RpcResponse {
183        let id = request.id.clone();
184
185        // Validate JSON-RPC version
186        if request.jsonrpc != "2.0" {
187            return RpcResponse::error(id, RpcError::invalid_request("Invalid JSON-RPC version"));
188        }
189
190        // Dispatch to appropriate handler
191        let result = match request.method.as_str() {
192            "search_files" => Self::handle_search_files(&request.params, id.clone()).await,
193            "list_files" => Self::handle_list_files(&request.params).await,
194            "find_references" => Self::handle_find_references(&request.params).await,
195            _ => return RpcResponse::error(id, RpcError::method_not_found()),
196        };
197
198        match result {
199            Ok(response) => RpcResponse::success(id, response),
200            Err(error) => RpcResponse::error(id, RpcError::internal_error(error.to_string())),
201        }
202    }
203
204    /// Handle search_files RPC method
205    ///
206    /// Performs fuzzy file search with the given pattern.
207    async fn handle_search_files(params: &Value, _id: Option<Value>) -> Result<Value> {
208        let request: SearchFilesRequest = serde_json::from_value(params.clone())
209            .context("Failed to parse search_files parameters")?;
210
211        // Validate workspace root
212        if !request.workspace_root.exists() {
213            return Err(anyhow::anyhow!(
214                "Workspace root does not exist: {}",
215                request.workspace_root.display()
216            ));
217        }
218
219        // Build configuration
220        let config = FileSearchConfig::new(request.pattern, request.workspace_root)
221            .with_limit(request.max_results)
222            .respect_gitignore(request.respect_gitignore);
223
224        // Perform search
225        let results = file_search_bridge::search_files(config, None)?;
226
227        // Convert to RPC response format
228        let matches: Vec<FileMatchRpc> = results
229            .matches
230            .into_iter()
231            .map(|m| FileMatchRpc {
232                path: m.path,
233                match_type: m.match_type,
234                score: m.score,
235                indices: m.indices,
236            })
237            .collect();
238
239        Ok(json!({
240            "matches": matches,
241            "total_match_count": results.total_match_count,
242            "truncated": matches.len() >= request.max_results,
243        }))
244    }
245
246    /// Handle list_files RPC method
247    ///
248    /// Lists all files in the workspace with optional exclusions.
249    async fn handle_list_files(params: &Value) -> Result<Value> {
250        let request: ListFilesRequest = serde_json::from_value(params.clone())
251            .context("Failed to parse list_files parameters")?;
252
253        // Validate workspace root
254        if !request.workspace_root.exists() {
255            return Err(anyhow::anyhow!(
256                "Workspace root does not exist: {}",
257                request.workspace_root.display()
258            ));
259        }
260
261        // Build configuration (empty pattern lists all files)
262        let mut config = FileSearchConfig::new(String::new(), request.workspace_root)
263            .with_limit(request.max_results)
264            .respect_gitignore(request.respect_gitignore);
265
266        for pattern in request.exclude_patterns {
267            config = config.exclude(pattern);
268        }
269
270        // Perform search
271        let results = file_search_bridge::search_files(config, None)?;
272
273        // Extract file paths
274        let files: Vec<String> = file_search_bridge::file_matches_only(results.matches)
275            .into_iter()
276            .map(|m| m.path)
277            .collect();
278        let total = files.len();
279
280        Ok(json!({
281            "files": files,
282            "total": total,
283        }))
284    }
285
286    /// Handle find_references RPC method (stub for future implementation)
287    ///
288    /// Finds all files containing a symbol reference.
289    async fn handle_find_references(params: &Value) -> Result<Value> {
290        // This would require more sophisticated symbol analysis
291        // For now, return a placeholder that could be implemented later
292        let _symbol: String = serde_json::from_value(params.clone())
293            .context("Failed to parse find_references parameters")?;
294
295        Ok(json!({
296            "matches": [],
297            "message": "find_references not yet implemented",
298        }))
299    }
300}
301
302/// Parse JSON-RPC request from raw JSON string
303///
304/// # Arguments
305///
306/// * `json_string` - Raw JSON request string
307///
308/// # Returns
309///
310/// Parsed RPC request or error response
311pub fn parse_rpc_request(json_string: &str) -> Result<RpcRequest, Box<RpcResponse>> {
312    match serde_json::from_str::<RpcRequest>(json_string) {
313        Ok(request) => Ok(request),
314        Err(err) => {
315            let error_response =
316                RpcResponse::error(None, RpcError::invalid_request(err.to_string()));
317            Err(Box::new(error_response))
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_rpc_error_codes() {
328        assert_eq!(RpcError::invalid_request("test").code, -32600);
329        assert_eq!(RpcError::method_not_found().code, -32601);
330        assert_eq!(RpcError::invalid_params("test").code, -32602);
331        assert_eq!(RpcError::internal_error("test").code, -32603);
332    }
333
334    #[test]
335    fn test_rpc_response_success() {
336        let response = RpcResponse::success(Some(json!(1)), json!({"ok": true}));
337        assert_eq!(response.jsonrpc, "2.0");
338        assert_eq!(response.id, Some(json!(1)));
339        assert!(response.result.is_some());
340        assert!(response.error.is_none());
341    }
342
343    #[test]
344    fn test_rpc_response_error() {
345        let error = RpcError::internal_error("test error");
346        let response = RpcResponse::error(Some(json!(1)), error);
347        assert_eq!(response.jsonrpc, "2.0");
348        assert_eq!(response.id, Some(json!(1)));
349        assert!(response.result.is_none());
350        assert!(response.error.is_some());
351    }
352
353    #[test]
354    fn test_search_files_request_parsing() {
355        let json = r#"{
356            "pattern": "main",
357            "workspace_root": "/workspace",
358            "max_results": 100,
359            "exclude_patterns": [],
360            "respect_gitignore": true
361        }"#;
362
363        let value: Value = serde_json::from_str(json).unwrap();
364        let request: SearchFilesRequest = serde_json::from_value(value).unwrap();
365
366        assert_eq!(request.pattern, "main");
367        assert_eq!(request.max_results, 100);
368        assert!(request.respect_gitignore);
369    }
370
371    #[test]
372    fn test_list_files_request_parsing() {
373        let json = r#"{
374            "workspace_root": "/workspace",
375            "exclude_patterns": ["**/node_modules/**"],
376            "respect_gitignore": true,
377            "max_results": 1000
378        }"#;
379
380        let value: Value = serde_json::from_str(json).unwrap();
381        let request: ListFilesRequest = serde_json::from_value(value).unwrap();
382
383        assert_eq!(request.exclude_patterns.len(), 1);
384        assert_eq!(request.max_results, 1000);
385    }
386
387    #[test]
388    fn test_parse_invalid_rpc_request() {
389        let invalid_json = "not valid json";
390        let result = parse_rpc_request(invalid_json);
391        result.unwrap_err();
392    }
393
394    #[test]
395    fn test_file_match_rpc_serialization() {
396        let file_match = FileMatchRpc {
397            path: "src/main.rs".to_string(),
398            match_type: FileMatchType::File,
399            score: 100,
400            indices: Some(vec![4, 5]),
401        };
402
403        let json = serde_json::to_string(&file_match).unwrap();
404        let deserialized: FileMatchRpc = serde_json::from_str(&json).unwrap();
405
406        assert_eq!(deserialized.path, "src/main.rs");
407        assert_eq!(deserialized.score, 100u32);
408        assert_eq!(deserialized.indices, Some(vec![4u32, 5u32]));
409    }
410}