foundry_mcp/core/installation/
cursor.rs1use crate::core::filesystem::write_file_atomic;
4use crate::core::installation::{
5 InstallationResult, UninstallationResult, add_server_to_config, create_cursor_server_config,
6 create_installation_result, create_uninstallation_result, get_cursor_mcp_config_path,
7 has_server_config, read_config_file, remove_server_from_config, validate_config_dir_writable,
8 write_config_file,
9};
10use crate::core::templates::ClientTemplate;
11use crate::core::templates::cursor_rules::CursorRulesTemplate;
12use crate::types::responses::EnvironmentStatus;
13use anyhow::{Context, Result};
14use std::fs;
15
16pub async fn install_for_cursor() -> Result<InstallationResult> {
18 let config_path = get_cursor_mcp_config_path()?;
19 let config_path_str = config_path.to_string_lossy().to_string();
20
21 validate_config_dir_writable(config_path.as_path())?;
22
23 let mut actions_taken = Vec::new();
24
25 let mut config =
27 read_config_file(&config_path).context("Failed to read existing MCP configuration")?;
28
29 let was_already_configured = has_server_config(&config, "foundry");
31
32 let server_config = create_cursor_server_config();
34
35 config = add_server_to_config(config, "foundry", server_config);
37
38 if was_already_configured {
39 actions_taken
40 .push("Updated existing Foundry MCP server in Cursor configuration".to_string());
41 } else {
42 actions_taken.push("Added Foundry MCP server to Cursor configuration".to_string());
43 }
44
45 write_config_file(&config_path, &config).context("Failed to write MCP configuration")?;
47 actions_taken.push(format!("Updated configuration file: {}", config_path_str));
48
49 crate::core::installation::validate_config(&config)
51 .context("Configuration validation failed")?;
52 actions_taken.push("Validated MCP configuration".to_string());
53
54 match install_cursor_rules_template(&config_path).await {
56 Ok(template_message) => {
57 actions_taken.push(template_message);
58 }
59 Err(e) => {
60 actions_taken.push(format!(
62 "Warning: Failed to install Cursor rules template: {}",
63 e
64 ));
65 }
66 }
67
68 Ok(create_installation_result(
69 true,
70 config_path_str,
71 actions_taken,
72 ))
73}
74
75pub async fn uninstall_from_cursor(remove_config: bool) -> Result<UninstallationResult> {
77 let config_path = get_cursor_mcp_config_path()?;
78 let config_path_str = config_path.to_string_lossy().to_string();
79
80 let mut actions_taken = Vec::new();
81 let mut files_removed = Vec::new();
82
83 let mut config = match read_config_file(&config_path) {
85 Ok(config) => config,
86 Err(e) => return Err(e),
87 };
88
89 if !has_server_config(&config, "foundry") {
91 return Err(anyhow::anyhow!(
92 "Foundry MCP server is not configured for Cursor"
93 ));
94 } else {
95 config = remove_server_from_config(config, "foundry");
97 actions_taken.push("Removed Foundry MCP server from Cursor configuration".to_string());
98 }
99
100 if config.mcp_servers.is_empty() && remove_config {
102 if config_path.exists() {
103 std::fs::remove_file(&config_path).context("Failed to remove configuration file")?;
104 files_removed.push(config_path_str.clone());
105 actions_taken.push(format!("Removed configuration file: {}", config_path_str));
106 }
107 } else {
108 write_config_file(&config_path, &config)
109 .context("Failed to write updated MCP configuration")?;
110 actions_taken.push(format!("Updated configuration file: {}", config_path_str));
111 }
112
113 match remove_cursor_rules_template(&config_path).await {
115 Ok(Some(template_message)) => {
116 actions_taken.push(template_message);
117 files_removed.push("Cursor rules template".to_string());
118 }
119 Ok(None) => {
120 }
122 Err(e) => {
123 actions_taken.push(format!(
125 "Warning: Failed to remove Cursor rules template: {}",
126 e
127 ));
128 }
129 }
130
131 Ok(create_uninstallation_result(
132 true,
133 config_path_str,
134 actions_taken,
135 files_removed,
136 ))
137}
138
139pub async fn get_cursor_status(detailed: bool) -> Result<EnvironmentStatus> {
141 let config_path = get_cursor_mcp_config_path()?;
142 let config_path_str = config_path.to_string_lossy().to_string();
143
144 let mut issues = Vec::new();
145 let mut installed = false;
146 let mut config_exists = false;
147 let mut binary_accessible = false;
148 let mut config_content = None;
149
150 if config_path.exists() {
152 config_exists = true;
153
154 if detailed {
155 config_content = Some(
156 std::fs::read_to_string(&config_path)
157 .unwrap_or_else(|_| "Error reading config file".to_string()),
158 );
159 }
160 } else {
161 issues.push("MCP configuration file does not exist".to_string());
162 }
163
164 if config_exists {
166 match read_config_file(&config_path) {
167 Ok(config) => {
168 if has_server_config(&config, "foundry") {
169 installed = true;
170
171 if let Some(server_config) =
173 crate::core::installation::get_server_config(&config, "foundry")
174 {
175 let command_path = std::path::Path::new(&server_config.command);
177 if command_path.is_absolute() {
178 binary_accessible = command_path.exists();
180 if !binary_accessible {
181 issues.push(format!(
182 "Configured binary does not exist: {}",
183 server_config.command
184 ));
185 }
186 } else {
187 binary_accessible = true;
189 }
190 }
191 } else {
192 issues.push("Foundry MCP server not found in configuration".to_string());
193 }
194 }
195 Err(e) => {
196 issues.push(format!("Failed to read configuration: {}", e));
197 }
198 }
199 }
200
201 Ok(EnvironmentStatus {
202 name: "cursor".to_string(),
203 installed,
204 config_path: config_path_str,
205 config_exists,
206 binary_path: if installed {
207 crate::core::installation::detect_binary_path()
208 .unwrap_or_else(|_| "unknown".to_string())
209 } else {
210 "unknown".to_string()
211 },
212 binary_accessible,
213 config_content,
214 issues,
215 })
216}
217
218pub fn is_cursor_configured() -> bool {
220 get_cursor_mcp_config_path().is_ok_and(|config_path| {
221 read_config_file(&config_path).is_ok_and(|config| has_server_config(&config, "foundry"))
222 })
223}
224
225async fn install_cursor_rules_template(config_path: &std::path::Path) -> Result<String> {
227 let config_dir = config_path
229 .parent()
230 .context("Failed to get config directory from config path")?;
231
232 let template_path = CursorRulesTemplate::file_path(config_dir)
234 .context("Failed to resolve Cursor rules template path")?;
235
236 if let Some(parent) = template_path.parent() {
238 fs::create_dir_all(parent)
239 .with_context(|| format!("Failed to create template directory: {:?}", parent))?;
240 }
241
242 let content = CursorRulesTemplate::content();
244
245 write_file_atomic(&template_path, content)
247 .with_context(|| format!("Failed to write Cursor rules template: {:?}", template_path))?;
248
249 Ok(format!(
251 "Created Cursor rules template: {}",
252 template_path.to_string_lossy()
253 ))
254}
255
256async fn remove_cursor_rules_template(config_path: &std::path::Path) -> Result<Option<String>> {
258 let config_dir = config_path
260 .parent()
261 .context("Failed to get config directory from config path")?;
262
263 let template_path = CursorRulesTemplate::file_path(config_dir)
265 .context("Failed to resolve Cursor rules template path")?;
266
267 if !template_path.exists() {
269 return Ok(None);
270 }
271
272 fs::remove_file(&template_path).with_context(|| {
274 format!(
275 "Failed to remove Cursor rules template: {:?}",
276 template_path
277 )
278 })?;
279
280 if let Some(parent) = template_path.parent() {
282 if parent.read_dir()?.next().is_none() && parent != config_dir {
284 fs::remove_dir(parent)
285 .with_context(|| format!("Failed to remove empty directory: {:?}", parent))?;
286 }
287 }
288
289 Ok(Some(format!(
291 "Removed Cursor rules template: {}",
292 template_path.to_string_lossy()
293 )))
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::test_utils::TestEnvironment;
300
301 #[test]
302 fn test_install_for_cursor_fresh_environment() {
303 let env = TestEnvironment::new().unwrap();
304
305 env.with_env_async(|| async {
306 let result = install_for_cursor().await;
307
308 assert!(
309 result.is_ok(),
310 "Install should succeed on fresh environment"
311 );
312 let install_result = result.unwrap();
313 assert!(install_result.success);
314 assert!(install_result.actions_taken.len() >= 3); assert!(
318 env.cursor_config_path().exists(),
319 "Config file should exist"
320 );
321 assert!(
322 env.cursor_config_path().is_file(),
323 "Config should be a file"
324 );
325
326 let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
328 assert!(config_content.contains("\"command\": \"foundry\""));
329 assert!(config_content.contains("mcpServers"));
330 });
331 }
332
333 #[test]
334 fn test_install_for_cursor_already_configured() {
335 let env = TestEnvironment::new().unwrap();
336
337 env.with_env_async(|| async {
338 env.create_cursor_config(&[("foundry", "/old/foundry/path")])
340 .unwrap();
341
342 let result = install_for_cursor().await;
343
344 assert!(
345 result.is_ok(),
346 "Install should succeed and overwrite existing configuration"
347 );
348 let install_result = result.unwrap();
349 assert!(install_result.success);
350 assert!(
351 install_result
352 .actions_taken
353 .iter()
354 .any(|action| action.contains("Updated existing Foundry MCP server"))
355 );
356 });
357 }
358
359 #[test]
360 fn test_install_for_cursor_overwrites_existing() {
361 let env = TestEnvironment::new().unwrap();
362
363 env.with_env_async(|| async {
364 env.create_cursor_config(&[("foundry", "/old/foundry/path")])
366 .unwrap();
367
368 let result = install_for_cursor().await;
369
370 assert!(
371 result.is_ok(),
372 "Install should succeed and overwrite existing configuration"
373 );
374 let install_result = result.unwrap();
375 assert!(install_result.success);
376 assert!(
377 install_result
378 .actions_taken
379 .iter()
380 .any(|action| action.contains("Updated existing Foundry MCP server"))
381 );
382
383 let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
385 assert!(config_content.contains("\"command\": \"foundry\""));
386 });
387 }
388
389 #[test]
390 fn test_install_for_cursor_config_validation() {
391 let env = TestEnvironment::new().unwrap();
392
393 env.with_env_async(|| async {
394 let result = install_for_cursor().await;
395
396 assert!(result.is_ok(), "Install should succeed and validate config");
397 let install_result = result.unwrap();
398 assert!(install_result.success);
399 assert!(
400 install_result
401 .actions_taken
402 .iter()
403 .any(|action| action.contains("Validated MCP configuration"))
404 );
405 });
406 }
407
408 #[test]
409 fn test_uninstall_from_cursor_configured() {
410 let env = TestEnvironment::new().unwrap();
411
412 env.with_env_async(|| async {
413 env.create_cursor_config(&[
415 ("foundry", "/usr/local/bin/foundry"),
416 ("other-server", "/other/binary"),
417 ])
418 .unwrap();
419
420 let result = uninstall_from_cursor(false).await;
421
422 assert!(
423 result.is_ok(),
424 "Uninstall should succeed when foundry is configured"
425 );
426 let uninstall_result = result.unwrap();
427 assert!(uninstall_result.success);
428 assert!(
429 uninstall_result
430 .actions_taken
431 .iter()
432 .any(|action| action.contains("Removed Foundry MCP server"))
433 );
434 assert!(uninstall_result.files_removed.is_empty()); let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
438 assert!(!config_content.contains("foundry"));
439 assert!(config_content.contains("other-server"));
440 });
441 }
442
443 #[test]
444 fn test_uninstall_from_cursor_not_configured() {
445 let env = TestEnvironment::new().unwrap();
446
447 env.with_env_async(|| async {
448 env.create_cursor_config(&[]).unwrap();
450
451 let result = uninstall_from_cursor(false).await;
452
453 assert!(
454 result.is_err(),
455 "Uninstall should fail when foundry is not configured"
456 );
457 let error_msg = result.unwrap_err().to_string();
458 assert!(error_msg.contains("not configured"));
459 });
460 }
461
462 #[test]
463 fn test_uninstall_from_cursor_not_configured_fails() {
464 let env = TestEnvironment::new().unwrap();
465
466 env.with_env_async(|| async {
467 env.create_cursor_config(&[]).unwrap();
469
470 let result = uninstall_from_cursor(false).await;
471
472 assert!(
473 result.is_err(),
474 "Uninstall should fail when foundry is not configured"
475 );
476 assert!(result.unwrap_err().to_string().contains("not configured"));
477 });
478 }
479
480 #[test]
481 fn test_uninstall_from_cursor_remove_config_when_empty() {
482 let env = TestEnvironment::new().unwrap();
483
484 env.with_env_async(|| async {
485 env.create_cursor_config(&[("foundry", "/usr/local/bin/foundry")])
487 .unwrap();
488
489 let result = uninstall_from_cursor(true).await;
490
491 assert!(
492 result.is_ok(),
493 "Uninstall should succeed and remove config when empty"
494 );
495 let uninstall_result = result.unwrap();
496 assert!(uninstall_result.success);
497 assert!(
498 uninstall_result
499 .actions_taken
500 .iter()
501 .any(|action| action.contains("Removed configuration file"))
502 );
503 assert!(
504 uninstall_result
505 .files_removed
506 .iter()
507 .any(|file| file.contains("mcp.json"))
508 );
509
510 assert!(!env.cursor_config_path().exists());
512 });
513 }
514
515 #[test]
516 fn test_get_cursor_status_not_installed() {
517 let env = TestEnvironment::new().unwrap();
518
519 env.with_env_async(|| async {
520 let result = get_cursor_status(false).await;
521
522 assert!(result.is_ok(), "Should be able to get Cursor status");
523 let status = result.unwrap();
524 assert_eq!(status.name, "cursor");
525 assert!(!status.installed);
526 assert!(!status.config_exists);
527 assert!(!status.binary_accessible);
528 assert!(!status.issues.is_empty());
529 assert!(
530 status
531 .issues
532 .iter()
533 .any(|issue| issue.contains("does not exist"))
534 );
535 });
536 }
537
538 #[test]
539 fn test_get_cursor_status_installed() {
540 let env = TestEnvironment::new().unwrap();
541
542 env.with_env_async(|| async {
543 let binary_path = env.create_mock_binary("foundry").unwrap();
544 env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
545 .unwrap();
546
547 let result = get_cursor_status(false).await;
548
549 assert!(result.is_ok(), "Should be able to get Cursor status");
550 let status = result.unwrap();
551 assert_eq!(status.name, "cursor");
552 assert!(status.installed);
553 assert!(status.config_exists);
554 assert!(status.binary_accessible);
555 assert!(status.issues.is_empty());
556 });
557 }
558
559 #[test]
560 fn test_get_cursor_status_detailed() {
561 let env = TestEnvironment::new().unwrap();
562
563 env.with_env_async(|| async {
564 let binary_path = env.create_mock_binary("foundry").unwrap();
565 env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
566 .unwrap();
567
568 let result = get_cursor_status(true).await;
569
570 assert!(
571 result.is_ok(),
572 "Should be able to get detailed Cursor status"
573 );
574 let status = result.unwrap();
575 assert!(status.config_content.is_some());
576 let config_content = status.config_content.unwrap();
577 assert!(config_content.contains("foundry"));
578 assert!(config_content.contains("mcpServers"));
579 });
580 }
581
582 #[test]
583 fn test_get_cursor_status_invalid_config() {
584 let env = TestEnvironment::new().unwrap();
585
586 env.with_env_async(|| async {
587 std::fs::create_dir_all(env.cursor_config_dir()).unwrap();
589 std::fs::write(env.cursor_config_path(), "invalid json content").unwrap();
590
591 let result = get_cursor_status(false).await;
592
593 assert!(result.is_ok(), "Should handle invalid config gracefully");
594 let status = result.unwrap();
595 assert!(!status.installed);
596 assert!(status.config_exists);
597 assert!(!status.binary_accessible);
598 assert!(
599 status
600 .issues
601 .iter()
602 .any(|issue| issue.contains("Failed to read configuration"))
603 );
604 });
605 }
606
607 #[test]
608 fn test_get_cursor_status_missing_binary() {
609 let env = TestEnvironment::new().unwrap();
610
611 env.with_env_async(|| async {
612 env.create_cursor_config(&[("foundry", "/nonexistent/foundry")])
614 .unwrap();
615
616 let result = get_cursor_status(false).await;
617
618 assert!(result.is_ok(), "Should handle missing binary gracefully");
619 let status = result.unwrap();
620 assert!(status.installed);
621 assert!(status.config_exists);
622 assert!(!status.binary_accessible);
623 assert!(
624 status
625 .issues
626 .iter()
627 .any(|issue| issue.contains("does not exist"))
628 );
629 });
630 }
631
632 #[test]
633 fn test_is_cursor_configured() {
634 let env = TestEnvironment::new().unwrap();
635
636 env.with_env_async(|| async {
637 assert!(!is_cursor_configured());
639
640 let binary_path = env.create_mock_binary("foundry").unwrap();
642 env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
643 .unwrap();
644
645 assert!(is_cursor_configured());
647 });
648 }
649
650 #[test]
651 fn test_binary_path_validation() {
652 let env = TestEnvironment::new().unwrap();
653 let binary_path = env.create_mock_binary("foundry").unwrap();
654
655 let binary_path_str = binary_path.to_string_lossy().to_string();
657 let validation_result = crate::core::installation::validate_binary_path(&binary_path_str);
658 assert!(
659 validation_result.is_ok(),
660 "Binary path validation should succeed for valid path"
661 );
662
663 let invalid_result = crate::core::installation::validate_binary_path("/nonexistent/path");
665 assert!(
666 invalid_result.is_err(),
667 "Binary path validation should fail for invalid path"
668 );
669 }
670
671 #[test]
672 fn test_config_validation() {
673 let env = TestEnvironment::new().unwrap();
674 let binary_path = env.create_mock_binary("foundry").unwrap();
675
676 let mut config = crate::core::installation::json_config::McpConfig {
678 mcp_servers: std::collections::HashMap::new(),
679 };
680 let server_config =
681 crate::core::installation::create_server_config(&binary_path.to_string_lossy());
682 config = crate::core::installation::add_server_to_config(config, "foundry", server_config);
683
684 let result = crate::core::installation::validate_config(&config);
686 assert!(result.is_ok());
687
688 let mut bad_config = crate::core::installation::json_config::McpConfig {
690 mcp_servers: std::collections::HashMap::new(),
691 };
692 let bad_server_config =
693 crate::core::installation::create_server_config("/nonexistent/path");
694 bad_config = crate::core::installation::add_server_to_config(
695 bad_config,
696 "foundry",
697 bad_server_config,
698 );
699
700 let result = crate::core::installation::validate_config(&bad_config);
701 assert!(result.is_err());
702 assert!(
703 result
704 .unwrap_err()
705 .to_string()
706 .contains("command does not exist")
707 );
708 }
709}