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 { code, message: message.into(), data: None }
89    }
90
91    /// Invalid request error (-32600)
92    pub fn invalid_request(message: impl Into<String>) -> Self {
93        Self::new(-32600, message)
94    }
95
96    /// Method not found error (-32601)
97    pub fn method_not_found() -> Self {
98        Self::new(-32601, "Method not found")
99    }
100
101    /// Invalid params error (-32602)
102    pub fn invalid_params(message: impl Into<String>) -> Self {
103        Self::new(-32602, message)
104    }
105
106    /// Internal error (-32603)
107    pub fn internal_error(message: impl Into<String>) -> Self {
108        Self::new(-32603, message)
109    }
110
111    /// Custom error code
112    pub fn custom(code: i32, message: impl Into<String>) -> Self {
113        Self::new(code, message)
114    }
115}
116
117/// RPC request envelope
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RpcRequest {
120    /// JSON-RPC version (always "2.0")
121    pub jsonrpc: String,
122    /// RPC method name
123    pub method: String,
124    /// RPC method parameters (varies by method)
125    pub params: Value,
126    /// Request ID (for responses)
127    pub id: Option<Value>,
128}
129
130/// RPC response envelope
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct RpcResponse {
133    /// JSON-RPC version
134    pub jsonrpc: String,
135    /// Response ID (matches request ID)
136    pub id: Option<Value>,
137    /// Success result (if successful)
138    pub result: Option<Value>,
139    /// Error response (if failed)
140    pub error: Option<RpcError>,
141}
142
143impl RpcResponse {
144    /// Create a successful response
145    pub fn success(id: Option<Value>, result: Value) -> Self {
146        Self {
147            jsonrpc: "2.0".to_string(),
148            id,
149            result: Some(result),
150            error: None,
151        }
152    }
153
154    /// Create an error response
155    pub fn error(id: Option<Value>, error: RpcError) -> Self {
156        Self {
157            jsonrpc: "2.0".to_string(),
158            id,
159            result: None,
160            error: Some(error),
161        }
162    }
163}
164
165/// Handler for file search RPC requests
166pub struct FileSearchRpcHandler;
167
168impl FileSearchRpcHandler {
169    /// Handle an incoming RPC request
170    ///
171    /// # Arguments
172    ///
173    /// * `request` - Parsed JSON-RPC request
174    ///
175    /// # Returns
176    ///
177    /// JSON-RPC response with result or error
178    pub async fn handle_request(request: RpcRequest) -> RpcResponse {
179        let id = request.id.clone();
180
181        // Validate JSON-RPC version
182        if request.jsonrpc != "2.0" {
183            return RpcResponse::error(id, RpcError::invalid_request("Invalid JSON-RPC version"));
184        }
185
186        // Dispatch to appropriate handler
187        let result = match request.method.as_str() {
188            "search_files" => Self::handle_search_files(&request.params, id.clone()).await,
189            "list_files" => Self::handle_list_files(&request.params).await,
190            "find_references" => Self::handle_find_references(&request.params).await,
191            _ => return RpcResponse::error(id, RpcError::method_not_found()),
192        };
193
194        match result {
195            Ok(response) => RpcResponse::success(id, response),
196            Err(error) => RpcResponse::error(id, RpcError::internal_error(error.to_string())),
197        }
198    }
199
200    /// Handle search_files RPC method
201    ///
202    /// Performs fuzzy file search with the given pattern.
203    async fn handle_search_files(params: &Value, _id: Option<Value>) -> Result<Value> {
204        let request: SearchFilesRequest =
205            serde_json::from_value(params.clone()).context("Failed to parse search_files parameters")?;
206
207        // Validate workspace root
208        if !tokio::fs::try_exists(&request.workspace_root).await.unwrap_or(false) {
209            return Err(anyhow::anyhow!("Workspace root does not exist: {}", request.workspace_root.display()));
210        }
211
212        // Build configuration
213        let max_results = file_search_bridge::effective_max_results(request.max_results);
214        let config = FileSearchConfig::new(request.pattern, request.workspace_root)
215            .with_limit(max_results)
216            .respect_gitignore(request.respect_gitignore);
217
218        // Perform search
219        let results = file_search_bridge::search_files_async(config, None).await?;
220
221        // Convert to RPC response format
222        let matches: Vec<FileMatchRpc> = results
223            .matches
224            .into_iter()
225            .map(|m| FileMatchRpc {
226                path: m.path,
227                match_type: m.match_type,
228                score: m.score,
229                indices: m.indices,
230            })
231            .collect();
232
233        Ok(json!({
234            "matches": matches,
235            "total_match_count": results.total_match_count,
236            "truncated": matches.len() >= max_results,
237        }))
238    }
239
240    /// Handle list_files RPC method
241    ///
242    /// Lists all files in the workspace with optional exclusions.
243    async fn handle_list_files(params: &Value) -> Result<Value> {
244        let request: ListFilesRequest =
245            serde_json::from_value(params.clone()).context("Failed to parse list_files parameters")?;
246
247        // Validate workspace root
248        if !tokio::fs::try_exists(&request.workspace_root).await.unwrap_or(false) {
249            return Err(anyhow::anyhow!("Workspace root does not exist: {}", request.workspace_root.display()));
250        }
251
252        // Build configuration (empty pattern lists all files)
253        let max_results = file_search_bridge::effective_max_results(request.max_results);
254        let mut config = FileSearchConfig::new(String::new(), request.workspace_root)
255            .with_limit(max_results)
256            .respect_gitignore(request.respect_gitignore);
257
258        for pattern in request.exclude_patterns {
259            config = config.exclude(pattern);
260        }
261
262        // Perform search
263        let results = file_search_bridge::search_files_async(config, None).await?;
264
265        // Extract file paths
266        let files: Vec<String> = file_search_bridge::file_matches_only(results.matches)
267            .into_iter()
268            .map(|m| m.path)
269            .collect();
270        let total = files.len();
271
272        Ok(json!({
273            "files": files,
274            "total": total,
275        }))
276    }
277
278    /// Handle find_references RPC method (stub for future implementation)
279    ///
280    /// Finds all files containing a symbol reference.
281    async fn handle_find_references(params: &Value) -> Result<Value> {
282        // This would require more sophisticated symbol analysis
283        // For now, return a placeholder that could be implemented later
284        let _symbol: String =
285            serde_json::from_value(params.clone()).context("Failed to parse find_references parameters")?;
286
287        Ok(json!({
288            "matches": [],
289            "message": "find_references not yet implemented",
290        }))
291    }
292}
293
294/// Parse JSON-RPC request from raw JSON string
295///
296/// # Arguments
297///
298/// * `json_string` - Raw JSON request string
299///
300/// # Returns
301///
302/// Parsed RPC request or error response
303pub fn parse_rpc_request(json_string: &str) -> Result<RpcRequest, Box<RpcResponse>> {
304    match serde_json::from_str::<RpcRequest>(json_string) {
305        Ok(request) => Ok(request),
306        Err(err) => {
307            let error_response = RpcResponse::error(None, RpcError::invalid_request(err.to_string()));
308            Err(Box::new(error_response))
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_rpc_error_codes() {
319        assert_eq!(RpcError::invalid_request("test").code, -32600);
320        assert_eq!(RpcError::method_not_found().code, -32601);
321        assert_eq!(RpcError::invalid_params("test").code, -32602);
322        assert_eq!(RpcError::internal_error("test").code, -32603);
323    }
324
325    #[test]
326    fn test_rpc_response_success() {
327        let response = RpcResponse::success(Some(json!(1)), json!({"ok": true}));
328        assert_eq!(response.jsonrpc, "2.0");
329        assert_eq!(response.id, Some(json!(1)));
330        assert!(response.result.is_some());
331        assert!(response.error.is_none());
332    }
333
334    #[test]
335    fn test_rpc_response_error() {
336        let error = RpcError::internal_error("test error");
337        let response = RpcResponse::error(Some(json!(1)), error);
338        assert_eq!(response.jsonrpc, "2.0");
339        assert_eq!(response.id, Some(json!(1)));
340        assert!(response.result.is_none());
341        assert!(response.error.is_some());
342    }
343
344    #[test]
345    fn test_search_files_request_parsing() {
346        let json = r#"{
347            "pattern": "main",
348            "workspace_root": "/workspace",
349            "max_results": 100,
350            "exclude_patterns": [],
351            "respect_gitignore": true
352        }"#;
353
354        let value: Value = serde_json::from_str(json).unwrap();
355        let request: SearchFilesRequest = serde_json::from_value(value).unwrap();
356
357        assert_eq!(request.pattern, "main");
358        assert_eq!(request.max_results, 100);
359        assert!(request.respect_gitignore);
360    }
361
362    #[test]
363    fn test_list_files_request_parsing() {
364        let json = r#"{
365            "workspace_root": "/workspace",
366            "exclude_patterns": ["**/node_modules/**"],
367            "respect_gitignore": true,
368            "max_results": 1000
369        }"#;
370
371        let value: Value = serde_json::from_str(json).unwrap();
372        let request: ListFilesRequest = serde_json::from_value(value).unwrap();
373
374        assert_eq!(request.exclude_patterns.len(), 1);
375        assert_eq!(request.max_results, 1000);
376    }
377
378    #[test]
379    fn test_parse_invalid_rpc_request() {
380        let invalid_json = "not valid json";
381        let result = parse_rpc_request(invalid_json);
382        result.unwrap_err();
383    }
384
385    #[test]
386    fn test_file_match_rpc_serialization() {
387        let file_match = FileMatchRpc {
388            path: "src/main.rs".to_string(),
389            match_type: FileMatchType::File,
390            score: 100,
391            indices: Some(vec![4, 5]),
392        };
393
394        let json = serde_json::to_string(&file_match).unwrap();
395        let deserialized: FileMatchRpc = serde_json::from_str(&json).unwrap();
396
397        assert_eq!(deserialized.path, "src/main.rs");
398        assert_eq!(deserialized.score, 100u32);
399        assert_eq!(deserialized.indices, Some(vec![4u32, 5u32]));
400    }
401}