1use 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#[derive(Debug, Clone)]
18pub enum DiffResult {
19 Accepted { content: String },
21 Rejected,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum ConnectionStatus {
28 Connected,
29 Disconnected,
30 Connecting,
31}
32
33#[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#[derive(Debug)]
52pub struct IdeClient {
53 http_client: reqwest::Client,
55 status: Arc<Mutex<ConnectionStatus>>,
57 ide_info: Option<IdeInfo>,
59 #[allow(dead_code)]
61 process_info: Option<IdeProcessInfo>,
62 port: Option<u16>,
64 auth_token: Option<String>,
66 session_id: Arc<Mutex<Option<String>>>,
68 request_id: Arc<Mutex<u64>>,
70 diff_responses: Arc<Mutex<HashMap<String, oneshot::Sender<DiffResult>>>>,
72 #[allow(dead_code)]
74 sse_receiver: Option<mpsc::Receiver<JsonRpcNotification>>,
75}
76
77impl IdeClient {
78 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 pub fn is_ide_available(&self) -> bool {
102 self.ide_info.is_some()
103 }
104
105 pub fn ide_name(&self) -> Option<&str> {
107 self.ide_info.as_ref().map(|i| i.display_name.as_str())
108 }
109
110 pub fn is_connected(&self) -> bool {
112 *self.status.lock().unwrap() == ConnectionStatus::Connected
113 }
114
115 pub fn status(&self) -> ConnectionStatus {
117 self.status.lock().unwrap().clone()
118 }
119
120 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 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 if self.establish_connection().await.is_ok() {
135 *self.status.lock().unwrap() = ConnectionStatus::Connected;
136 return Ok(());
137 }
138 }
139
140 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 async fn read_connection_config(&self) -> Option<ConnectionConfig> {
162 let temp_dir = env::temp_dir();
163
164 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 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 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 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 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 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 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 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 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 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 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 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 fn parse_sse_response(text: &str) -> Result<JsonRpcResponse, String> {
339 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 serde_json::from_str(text).map_err(|e| format!("Failed to parse response: {}", e))
348 }
349
350 fn next_request_id(&self) -> u64 {
352 let mut id = self.request_id.lock().unwrap();
353 *id += 1;
354 *id
355 }
356
357 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 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 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 let response = self.send_request("tools/call", params).await;
431
432 if let Err(e) = response {
433 let mut responses = self.diff_responses.lock().unwrap();
435 responses.remove(file_path);
436 return Err(e);
437 }
438
439 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 let mut responses = self.diff_responses.lock().unwrap();
446 responses.remove(file_path);
447 Err(IdeError::NoResponse)
448 }
449 }
450 }
451
452 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 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 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(¶ms.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(¶ms.file_path) {
511 let _ = tx.send(DiffResult::Rejected);
512 }
513 }
514 }
515 "ide/contextUpdate" => {
516 }
519 _ => {
520 }
522 }
523 }
524
525 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 if let Some(result) = response.result
555 && let Ok(tool_result) = serde_json::from_value::<ToolCallResult>(result)
556 {
557 for content in tool_result.content {
559 if content.content_type == "text"
560 && let Some(text) = content.text
561 {
562 if let Ok(diag_response) = serde_json::from_str::<DiagnosticsResponse>(&text) {
564 return Ok(diag_response);
565 }
566 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 Ok(DiagnosticsResponse {
588 diagnostics: Vec::new(),
589 total_errors: 0,
590 total_warnings: 0,
591 })
592 }
593
594 pub async fn disconnect(&mut self) {
596 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 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}