syncable_cli/agent/ide/
client.rs

1//! MCP Client for IDE Communication
2//!
3//! Connects to the IDE's MCP server via HTTP SSE and provides methods
4//! for opening diffs and receiving notifications.
5
6use super::detect::{IdeInfo, IdeProcessInfo, detect_ide, get_ide_process_info};
7use super::types::*;
8use std::collections::HashMap;
9use std::env;
10use std::fs;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14use tokio::sync::{mpsc, oneshot};
15
16/// Result of a diff operation
17#[derive(Debug, Clone)]
18pub enum DiffResult {
19    /// User accepted the diff, possibly with edits
20    Accepted { content: String },
21    /// User rejected the diff
22    Rejected,
23}
24
25/// IDE connection state
26#[derive(Debug, Clone, PartialEq)]
27pub enum ConnectionStatus {
28    Connected,
29    Disconnected,
30    Connecting,
31}
32
33/// Errors that can occur during IDE operations
34#[derive(Debug, thiserror::Error)]
35pub enum IdeError {
36    #[error("IDE not detected")]
37    NotDetected,
38    #[error("Connection failed: {0}")]
39    ConnectionFailed(String),
40    #[error("Request failed: {0}")]
41    RequestFailed(String),
42    #[error("No response received")]
43    NoResponse,
44    #[error("Operation cancelled")]
45    Cancelled,
46    #[error("IO error: {0}")]
47    Io(#[from] std::io::Error),
48}
49
50/// MCP Client for IDE communication
51#[derive(Debug)]
52pub struct IdeClient {
53    /// HTTP client
54    http_client: reqwest::Client,
55    /// Connection state
56    status: Arc<Mutex<ConnectionStatus>>,
57    /// Detected IDE info
58    ide_info: Option<IdeInfo>,
59    /// IDE process info (for future use)
60    #[allow(dead_code)]
61    process_info: Option<IdeProcessInfo>,
62    /// Server port
63    port: Option<u16>,
64    /// Auth token
65    auth_token: Option<String>,
66    /// Session ID for MCP
67    session_id: Arc<Mutex<Option<String>>>,
68    /// Request ID counter
69    request_id: Arc<Mutex<u64>>,
70    /// Pending diff responses
71    diff_responses: Arc<Mutex<HashMap<String, oneshot::Sender<DiffResult>>>>,
72    /// SSE event receiver (for future use)
73    #[allow(dead_code)]
74    sse_receiver: Option<mpsc::Receiver<JsonRpcNotification>>,
75}
76
77impl IdeClient {
78    /// Create a new IDE client (does not connect automatically)
79    pub async fn new() -> Self {
80        let process_info = get_ide_process_info().await;
81        let ide_info = detect_ide(process_info.as_ref());
82
83        Self {
84            http_client: reqwest::Client::builder()
85                .timeout(Duration::from_secs(30))
86                .build()
87                .unwrap_or_default(),
88            status: Arc::new(Mutex::new(ConnectionStatus::Disconnected)),
89            ide_info,
90            process_info,
91            port: None,
92            auth_token: None,
93            session_id: Arc::new(Mutex::new(None)),
94            request_id: Arc::new(Mutex::new(0)),
95            diff_responses: Arc::new(Mutex::new(HashMap::new())),
96            sse_receiver: None,
97        }
98    }
99
100    /// Check if IDE integration is available
101    pub fn is_ide_available(&self) -> bool {
102        self.ide_info.is_some()
103    }
104
105    /// Get the detected IDE name
106    pub fn ide_name(&self) -> Option<&str> {
107        self.ide_info.as_ref().map(|i| i.display_name.as_str())
108    }
109
110    /// Check if connected to IDE
111    pub fn is_connected(&self) -> bool {
112        *self.status.lock().unwrap() == ConnectionStatus::Connected
113    }
114
115    /// Get connection status
116    pub fn status(&self) -> ConnectionStatus {
117        self.status.lock().unwrap().clone()
118    }
119
120    /// Try to connect to the IDE server
121    pub async fn connect(&mut self) -> Result<(), IdeError> {
122        if self.ide_info.is_none() {
123            return Err(IdeError::NotDetected);
124        }
125
126        *self.status.lock().unwrap() = ConnectionStatus::Connecting;
127
128        // Try to read connection config from file
129        if let Some(config) = self.read_connection_config().await {
130            self.port = Some(config.port);
131            self.auth_token = config.auth_token.clone();
132
133            // Try to establish connection
134            if self.establish_connection().await.is_ok() {
135                *self.status.lock().unwrap() = ConnectionStatus::Connected;
136                return Ok(());
137            }
138        }
139
140        // Try environment variables as fallback
141        if let Ok(port_str) = env::var("SYNCABLE_CLI_IDE_SERVER_PORT")
142            && let Ok(port) = port_str.parse::<u16>()
143        {
144            self.port = Some(port);
145            self.auth_token = env::var("SYNCABLE_CLI_IDE_AUTH_TOKEN").ok();
146
147            if self.establish_connection().await.is_ok() {
148                *self.status.lock().unwrap() = ConnectionStatus::Connected;
149                return Ok(());
150            }
151        }
152
153        *self.status.lock().unwrap() = ConnectionStatus::Disconnected;
154        Err(IdeError::ConnectionFailed(
155            "Could not connect to IDE companion extension".to_string(),
156        ))
157    }
158
159    /// Read connection config from port file
160    /// Supports both Syncable and Gemini CLI companion extensions
161    async fn read_connection_config(&self) -> Option<ConnectionConfig> {
162        let temp_dir = env::temp_dir();
163
164        // Debug: show where we're looking
165        if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
166            eprintln!(
167                "[IDE Debug] Looking for port files in temp_dir: {:?}",
168                temp_dir
169            );
170        }
171
172        // Try Syncable extension first - scan all port files, match by workspace
173        let syncable_port_dir = temp_dir.join("syncable").join("ide");
174        if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
175            eprintln!(
176                "[IDE Debug] Checking Syncable dir: {:?} (exists: {})",
177                syncable_port_dir,
178                syncable_port_dir.exists()
179            );
180        }
181        if let Some(config) =
182            self.find_port_file_by_workspace(&syncable_port_dir, "syncable-ide-server")
183        {
184            if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
185                eprintln!("[IDE Debug] Found Syncable config: port={}", config.port);
186            }
187            return Some(config);
188        }
189
190        // Try Gemini CLI extension (for compatibility)
191        let gemini_port_dir = temp_dir.join("gemini").join("ide");
192        if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
193            eprintln!(
194                "[IDE Debug] Checking Gemini dir: {:?} (exists: {})",
195                gemini_port_dir,
196                gemini_port_dir.exists()
197            );
198        }
199        if let Some(config) =
200            self.find_port_file_by_workspace(&gemini_port_dir, "gemini-ide-server")
201        {
202            if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
203                eprintln!("[IDE Debug] Found Gemini config: port={}", config.port);
204            }
205            return Some(config);
206        }
207
208        if cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok() {
209            eprintln!("[IDE Debug] No port file found in either location");
210        }
211        None
212    }
213
214    /// Find a port file in a directory by scanning all files and matching workspace path
215    fn find_port_file_by_workspace(&self, dir: &PathBuf, prefix: &str) -> Option<ConnectionConfig> {
216        let entries = fs::read_dir(dir).ok()?;
217
218        let debug = cfg!(debug_assertions) || env::var("SYNCABLE_DEBUG").is_ok();
219
220        for entry in entries.flatten() {
221            let filename = entry.file_name().to_string_lossy().to_string();
222            // Match any file starting with the prefix and ending with .json
223            if filename.starts_with(prefix) && filename.ends_with(".json") {
224                if debug {
225                    eprintln!("[IDE Debug] Found port file: {:?}", entry.path());
226                }
227                if let Ok(content) = fs::read_to_string(entry.path())
228                    && let Ok(config) = serde_json::from_str::<ConnectionConfig>(&content)
229                {
230                    if debug {
231                        eprintln!(
232                            "[IDE Debug] Config workspace_path: {:?}",
233                            config.workspace_path
234                        );
235                    }
236                    if self.validate_workspace_path(&config.workspace_path) {
237                        return Some(config);
238                    } else if debug {
239                        let cwd = env::current_dir().ok();
240                        eprintln!("[IDE Debug] Workspace path did not match cwd: {:?}", cwd);
241                    }
242                }
243            }
244        }
245        None
246    }
247
248    /// Validate that the workspace path matches our current directory
249    fn validate_workspace_path(&self, workspace_path: &Option<String>) -> bool {
250        let Some(ws_path) = workspace_path else {
251            return false;
252        };
253
254        if ws_path.is_empty() {
255            return false;
256        }
257
258        let cwd = match env::current_dir() {
259            Ok(p) => p,
260            Err(_) => return false,
261        };
262
263        // Check if cwd is within any of the workspace paths
264        for path in ws_path.split(std::path::MAIN_SEPARATOR) {
265            let ws = PathBuf::from(path);
266            if cwd.starts_with(&ws) || ws.starts_with(&cwd) {
267                return true;
268            }
269        }
270
271        false
272    }
273
274    /// Establish HTTP connection and initialize MCP session
275    async fn establish_connection(&mut self) -> Result<(), IdeError> {
276        let port = self
277            .port
278            .ok_or(IdeError::ConnectionFailed("No port".to_string()))?;
279        let url = format!("http://127.0.0.1:{}/mcp", port);
280
281        // Build initialize request
282        let init_request = JsonRpcRequest::new(
283            self.next_request_id(),
284            "initialize",
285            serde_json::to_value(InitializeParams {
286                protocol_version: "2024-11-05".to_string(),
287                client_info: ClientInfo {
288                    name: "syncable-cli".to_string(),
289                    version: env!("CARGO_PKG_VERSION").to_string(),
290                },
291                capabilities: ClientCapabilities {},
292            })
293            .unwrap(),
294        );
295
296        // Send initialize request
297        let mut request = self
298            .http_client
299            .post(&url)
300            .header("Accept", "application/json, text/event-stream")
301            .json(&init_request);
302
303        if let Some(token) = &self.auth_token {
304            request = request.header("Authorization", format!("Bearer {}", token));
305        }
306
307        let response = request
308            .send()
309            .await
310            .map_err(|e| IdeError::ConnectionFailed(e.to_string()))?;
311
312        // Get session ID from response header
313        if let Some(session_id) = response.headers().get("mcp-session-id")
314            && let Ok(id) = session_id.to_str()
315        {
316            *self.session_id.lock().unwrap() = Some(id.to_string());
317        }
318
319        // Parse response (SSE format: "event: message\ndata: {json}")
320        let response_text = response
321            .text()
322            .await
323            .map_err(|e| IdeError::ConnectionFailed(e.to_string()))?;
324
325        let response_data: JsonRpcResponse =
326            Self::parse_sse_response(&response_text).map_err(IdeError::ConnectionFailed)?;
327
328        if response_data.error.is_some() {
329            return Err(IdeError::ConnectionFailed(
330                response_data.error.map(|e| e.message).unwrap_or_default(),
331            ));
332        }
333
334        Ok(())
335    }
336
337    /// Parse SSE response format to extract JSON
338    fn parse_sse_response(text: &str) -> Result<JsonRpcResponse, String> {
339        // SSE format: "event: message\ndata: {json}\n\n"
340        for line in text.lines() {
341            if let Some(json_str) = line.strip_prefix("data: ") {
342                return serde_json::from_str(json_str)
343                    .map_err(|e| format!("Failed to parse JSON: {}", e));
344            }
345        }
346        // Fallback: try parsing entire response as JSON (for non-SSE responses)
347        serde_json::from_str(text).map_err(|e| format!("Failed to parse response: {}", e))
348    }
349
350    /// Get next request ID
351    fn next_request_id(&self) -> u64 {
352        let mut id = self.request_id.lock().unwrap();
353        *id += 1;
354        *id
355    }
356
357    /// Send an MCP request
358    async fn send_request(
359        &self,
360        method: &str,
361        params: serde_json::Value,
362    ) -> Result<JsonRpcResponse, IdeError> {
363        let port = self
364            .port
365            .ok_or(IdeError::ConnectionFailed("Not connected".to_string()))?;
366        let url = format!("http://127.0.0.1:{}/mcp", port);
367
368        let request = JsonRpcRequest::new(self.next_request_id(), method, params);
369
370        let mut http_request = self
371            .http_client
372            .post(&url)
373            .header("Accept", "application/json, text/event-stream")
374            .json(&request);
375
376        if let Some(token) = &self.auth_token {
377            http_request = http_request.header("Authorization", format!("Bearer {}", token));
378        }
379
380        if let Some(session_id) = &*self.session_id.lock().unwrap() {
381            http_request = http_request.header("mcp-session-id", session_id);
382        }
383
384        let response = http_request
385            .send()
386            .await
387            .map_err(|e| IdeError::RequestFailed(e.to_string()))?;
388
389        let response_text = response
390            .text()
391            .await
392            .map_err(|e| IdeError::RequestFailed(e.to_string()))?;
393
394        Self::parse_sse_response(&response_text).map_err(IdeError::RequestFailed)
395    }
396
397    /// Open a diff view in the IDE
398    ///
399    /// This sends the file path and new content to the IDE, which will show
400    /// a diff view. The method returns when the user accepts or rejects the diff.
401    pub async fn open_diff(
402        &self,
403        file_path: &str,
404        new_content: &str,
405    ) -> Result<DiffResult, IdeError> {
406        if !self.is_connected() {
407            return Err(IdeError::ConnectionFailed(
408                "Not connected to IDE".to_string(),
409            ));
410        }
411
412        let params = serde_json::to_value(ToolCallParams {
413            name: "openDiff".to_string(),
414            arguments: serde_json::to_value(OpenDiffArgs {
415                file_path: file_path.to_string(),
416                new_content: new_content.to_string(),
417            })
418            .unwrap(),
419        })
420        .unwrap();
421
422        // Create a channel to receive the diff result
423        let (tx, rx) = oneshot::channel();
424        {
425            let mut responses = self.diff_responses.lock().unwrap();
426            responses.insert(file_path.to_string(), tx);
427        }
428
429        // Send the openDiff request
430        let response = self.send_request("tools/call", params).await;
431
432        if let Err(e) = response {
433            // Remove the pending response
434            let mut responses = self.diff_responses.lock().unwrap();
435            responses.remove(file_path);
436            return Err(e);
437        }
438
439        // Wait for the notification (with timeout)
440        match tokio::time::timeout(Duration::from_secs(300), rx).await {
441            Ok(Ok(result)) => Ok(result),
442            Ok(Err(_)) => Err(IdeError::Cancelled),
443            Err(_) => {
444                // Timeout - remove pending response
445                let mut responses = self.diff_responses.lock().unwrap();
446                responses.remove(file_path);
447                Err(IdeError::NoResponse)
448            }
449        }
450    }
451
452    /// Close a diff view in the IDE
453    pub async fn close_diff(&self, file_path: &str) -> Result<Option<String>, IdeError> {
454        if !self.is_connected() {
455            return Err(IdeError::ConnectionFailed(
456                "Not connected to IDE".to_string(),
457            ));
458        }
459
460        let params = serde_json::to_value(ToolCallParams {
461            name: "closeDiff".to_string(),
462            arguments: serde_json::to_value(CloseDiffArgs {
463                file_path: file_path.to_string(),
464                suppress_notification: Some(false),
465            })
466            .unwrap(),
467        })
468        .unwrap();
469
470        let response = self.send_request("tools/call", params).await?;
471
472        // Parse the response to get content if available
473        if let Some(result) = response.result
474            && let Ok(tool_result) = serde_json::from_value::<ToolCallResult>(result)
475        {
476            for content in tool_result.content {
477                if content.content_type == "text"
478                    && let Some(text) = content.text
479                    && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text)
480                    && let Some(content) = parsed.get("content").and_then(|c| c.as_str())
481                {
482                    return Ok(Some(content.to_string()));
483                }
484            }
485        }
486
487        Ok(None)
488    }
489
490    /// Handle an incoming notification from the IDE
491    pub fn handle_notification(&self, notification: JsonRpcNotification) {
492        match notification.method.as_str() {
493            "ide/diffAccepted" => {
494                if let Ok(params) =
495                    serde_json::from_value::<IdeDiffAcceptedParams>(notification.params)
496                {
497                    let mut responses = self.diff_responses.lock().unwrap();
498                    if let Some(tx) = responses.remove(&params.file_path) {
499                        let _ = tx.send(DiffResult::Accepted {
500                            content: params.content,
501                        });
502                    }
503                }
504            }
505            "ide/diffRejected" | "ide/diffClosed" => {
506                if let Ok(params) =
507                    serde_json::from_value::<IdeDiffRejectedParams>(notification.params)
508                {
509                    let mut responses = self.diff_responses.lock().unwrap();
510                    if let Some(tx) = responses.remove(&params.file_path) {
511                        let _ = tx.send(DiffResult::Rejected);
512                    }
513                }
514            }
515            "ide/contextUpdate" => {
516                // Handle IDE context updates (e.g., open files)
517                // This could be used to show relevant context in the agent
518            }
519            _ => {
520                // Unknown notification
521            }
522        }
523    }
524
525    /// Get diagnostics from the IDE's language servers
526    ///
527    /// This queries the IDE for all diagnostic messages (errors, warnings, etc.)
528    /// from the active language servers (rust-analyzer, ESLint, TypeScript, etc.)
529    ///
530    /// If `file_path` is provided, returns diagnostics only for that file.
531    /// Otherwise returns all diagnostics across the workspace.
532    pub async fn get_diagnostics(
533        &self,
534        file_path: Option<&str>,
535    ) -> Result<DiagnosticsResponse, IdeError> {
536        if !self.is_connected() {
537            return Err(IdeError::ConnectionFailed(
538                "Not connected to IDE".to_string(),
539            ));
540        }
541
542        let params = serde_json::to_value(ToolCallParams {
543            name: "getDiagnostics".to_string(),
544            arguments: serde_json::to_value(GetDiagnosticsArgs {
545                uri: file_path.map(|p| format!("file://{}", p)),
546            })
547            .unwrap(),
548        })
549        .unwrap();
550
551        let response = self.send_request("tools/call", params).await?;
552
553        // Parse the response
554        if let Some(result) = response.result
555            && let Ok(tool_result) = serde_json::from_value::<ToolCallResult>(result)
556        {
557            // Look for the text content with diagnostics
558            for content in tool_result.content {
559                if content.content_type == "text"
560                    && let Some(text) = content.text
561                {
562                    // Try to parse as DiagnosticsResponse
563                    if let Ok(diag_response) = serde_json::from_str::<DiagnosticsResponse>(&text) {
564                        return Ok(diag_response);
565                    }
566                    // Try parsing as raw array of diagnostics
567                    if let Ok(diagnostics) = serde_json::from_str::<Vec<Diagnostic>>(&text) {
568                        let total_errors = diagnostics
569                            .iter()
570                            .filter(|d| d.severity == DiagnosticSeverity::Error)
571                            .count() as u32;
572                        let total_warnings = diagnostics
573                            .iter()
574                            .filter(|d| d.severity == DiagnosticSeverity::Warning)
575                            .count() as u32;
576                        return Ok(DiagnosticsResponse {
577                            diagnostics,
578                            total_errors,
579                            total_warnings,
580                        });
581                    }
582                }
583            }
584        }
585
586        // No diagnostics found - return empty response
587        Ok(DiagnosticsResponse {
588            diagnostics: Vec::new(),
589            total_errors: 0,
590            total_warnings: 0,
591        })
592    }
593
594    /// Disconnect from the IDE
595    pub async fn disconnect(&mut self) {
596        // Close any pending diffs
597        let pending: Vec<String> = {
598            let responses = self.diff_responses.lock().unwrap();
599            responses.keys().cloned().collect()
600        };
601
602        for file_path in pending {
603            let _ = self.close_diff(&file_path).await;
604        }
605
606        *self.status.lock().unwrap() = ConnectionStatus::Disconnected;
607        *self.session_id.lock().unwrap() = None;
608    }
609}
610
611impl Default for IdeClient {
612    fn default() -> Self {
613        // Create with blocking runtime for sync context
614        tokio::runtime::Handle::current().block_on(Self::new())
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[tokio::test]
623    async fn test_ide_client_creation() {
624        let client = IdeClient::new().await;
625        assert!(!client.is_connected());
626    }
627
628    #[test]
629    fn test_diff_result() {
630        let accepted = DiffResult::Accepted {
631            content: "test".to_string(),
632        };
633        match accepted {
634            DiffResult::Accepted { content } => assert_eq!(content, "test"),
635            _ => panic!("Expected Accepted"),
636        }
637    }
638}