foundry_mcp/
test_utils.rs

1//! Test utilities for Foundry CLI integration tests
2//!
3//! This module provides the TestEnvironment struct and helper functions
4//! that can be used by the test crate.
5
6use crate::cli::args::*;
7use crate::types::responses::{InstallResponse, UninstallResponse};
8use anyhow::Result;
9use std::env;
10use std::future::Future;
11use std::path::PathBuf;
12use std::sync::{Mutex, MutexGuard};
13use tempfile::TempDir;
14
15/// Global mutex to ensure tests don't interfere with each other when setting environment variables
16static TEST_MUTEX: Mutex<()> = Mutex::new(());
17
18/// Environment variable guard that restores the original value when dropped
19pub struct EnvVarGuard {
20    key: String,
21    original_value: Option<String>,
22}
23
24impl Drop for EnvVarGuard {
25    fn drop(&mut self) {
26        unsafe {
27            if let Some(value) = &self.original_value {
28                env::set_var(&self.key, value);
29            } else {
30                env::remove_var(&self.key);
31            }
32        }
33    }
34}
35
36/// Helper function to temporarily set an environment variable with automatic cleanup
37pub fn env_var_guard(key: &str, value: &str) -> EnvVarGuard {
38    let original_value = env::var(key).ok();
39    unsafe {
40        env::set_var(key, value);
41    }
42    EnvVarGuard {
43        key: key.to_string(),
44        original_value,
45    }
46}
47
48/// Test environment that sets up a temporary foundry directory with proper isolation
49/// Uses thread-safe environment variable manipulation following CLI testing best practices
50pub struct TestEnvironment {
51    pub temp_dir: TempDir,
52    pub original_home: Option<String>,
53    _lock: MutexGuard<'static, ()>, // Held for the lifetime of the test environment
54}
55
56impl TestEnvironment {
57    /// Create a new test environment with isolated foundry directory
58    /// Sets HOME environment variable in a thread-safe manner
59    pub fn new() -> Result<Self> {
60        // Acquire global lock to prevent parallel tests from interfering
61        // Use expect instead of unwrap to handle poisoned mutex gracefully
62        let lock = TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
63            // Clear the poisoned state and acquire the lock
64            poisoned.into_inner()
65        });
66
67        let temp_dir = TempDir::new()?;
68        let original_home = env::var("HOME").ok();
69
70        // Set HOME to temp directory so foundry uses temp/.foundry
71        unsafe {
72            env::set_var("HOME", temp_dir.path());
73        }
74
75        Ok(TestEnvironment {
76            temp_dir,
77            original_home,
78            _lock: lock,
79        })
80    }
81
82    /// Get the foundry directory path within the test environment
83    pub fn foundry_dir(&self) -> std::path::PathBuf {
84        self.temp_dir.path().join(".foundry")
85    }
86
87    /// Create valid test arguments for create_project
88    pub fn create_project_args(&self, project_name: &str) -> CreateProjectArgs {
89        CreateProjectArgs {
90            project_name: project_name.to_string(),
91            vision: "This is a comprehensive test vision that meets all validation requirements. It describes a revolutionary software project that aims to solve complex problems in the development workflow. The project targets developers and teams who need better tooling for managing project contexts and specifications. Our unique value proposition includes seamless AI integration and deterministic project management that enhances rather than replaces existing workflows.".to_string(),
92            tech_stack: "This project leverages Rust as the primary programming language for its performance and safety guarantees. We use clap for CLI argument parsing, serde for JSON serialization, anyhow for error handling, and chrono for timestamp management. The architecture follows modular design principles with clear separation between CLI interfaces, core business logic, and utility functions. For deployment, we target cross-platform compatibility with distribution through cargo install.".to_string(),
93            summary: "A comprehensive Rust-based project management CLI tool that creates structured contexts for AI-assisted software development with atomic file operations and rich JSON responses.".to_string(),
94        }
95    }
96
97    /// Create valid test arguments for create_spec
98    pub fn create_spec_args(&self, project_name: &str, feature_name: &str) -> CreateSpecArgs {
99        CreateSpecArgs {
100            project_name: project_name.to_string(),
101            feature_name: feature_name.to_string(),
102            spec: "# Feature Name\n\n## Overview\nThis specification defines a comprehensive feature implementation that includes detailed requirements, functional specifications, and behavioral expectations.\n\n## Requirements\nThe feature should integrate seamlessly with existing system architecture while providing robust error handling and user-friendly interfaces. Implementation should follow established patterns and include proper testing coverage.".to_string(),
103            notes: "# Implementation Notes\n\n## Security Considerations\nImplementation notes include important considerations for security, performance, and maintainability.\n\n## Error Handling\nSpecial attention should be paid to error handling and edge cases.\n\n## Dependencies\nConsider using established libraries where appropriate and ensure compatibility with existing system components.".to_string(),
104            tasks: "Create feature scaffolding and basic structure, Implement core functionality with proper error handling, Add comprehensive test coverage for all scenarios, Update documentation and user guides, Perform integration testing with existing features, Conduct code review and optimization".to_string(),
105        }
106    }
107
108    /// Create test arguments for load_project
109    pub fn load_project_args(&self, project_name: &str) -> LoadProjectArgs {
110        LoadProjectArgs {
111            project_name: project_name.to_string(),
112        }
113    }
114
115    /// Create test arguments for update_spec with single file update
116    pub fn update_spec_args_single(
117        &self,
118        project_name: &str,
119        spec_name: &str,
120        file_type: &str,
121    ) -> UpdateSpecArgs {
122        let content = match file_type {
123            "spec" => "\n## Requirements\nUpdated content for testing that meets the minimum length requirements and provides comprehensive information for the specification update.\n".to_string(),
124            _ => "Updated content for testing that meets the minimum length requirements and provides comprehensive information for the specification update.".to_string(),
125        };
126
127        let command = match file_type {
128            "spec" => serde_json::json!({
129                "target": "spec",
130                "command": "append_to_section",
131                "selector": {"type": "section", "value": "## Requirements"},
132                "content": content
133            }),
134            "task-list" | "tasks" => {
135                if spec_name.contains("lifecycle_feature") || spec_name.contains("lifecycle") {
136                    serde_json::json!([{
137                        "target": "tasks",
138                        "command": "upsert_task",
139                        "selector": {"type": "task_text", "value": "Initial setup complete"},
140                        "content": "- [x] Initial setup complete"
141                    }])
142                } else {
143                    serde_json::json!([{
144                        "target": "tasks",
145                        "command": "upsert_task",
146                        "selector": {"type": "task_text", "value": "Test task"},
147                        "content": "- [ ] Test task"
148                    }])
149                }
150            }
151            "notes" => serde_json::json!({
152                "target": "notes",
153                "command": "append_to_section",
154                "selector": {"type": "section", "value": "## Security Considerations"},
155                "content": content
156            }),
157            _ => panic!("Invalid file_type: {}", file_type),
158        };
159
160        // If tasks, we already created an array of commands; otherwise wrap single command in an array
161        let commands_json = if file_type == "task-list" || file_type == "tasks" {
162            command
163        } else {
164            serde_json::json!([command])
165        };
166
167        UpdateSpecArgs {
168            project_name: project_name.to_string(),
169            spec_name: spec_name.to_string(),
170            commands: serde_json::to_string(&commands_json).unwrap(),
171        }
172    }
173
174    /// Create test arguments for update_spec with multiple file updates
175    pub fn update_spec_args_multi(
176        &self,
177        project_name: &str,
178        spec_name: &str,
179        spec_content: Option<&str>,
180        tasks_content: Option<&str>,
181        notes_content: Option<&str>,
182    ) -> UpdateSpecArgs {
183        let mut commands: Vec<serde_json::Value> = Vec::new();
184
185        if let Some(spec) = spec_content {
186            commands.push(serde_json::json!({
187                "target": "spec",
188                "command": "append_to_section",
189                "selector": {"type": "section", "value": "## Implementation"},
190                "content": spec
191            }));
192        }
193
194        if let Some(tasks) = tasks_content {
195            commands.push(serde_json::json!({
196                "target": "tasks",
197                "command": "upsert_task",
198                "selector": {"type": "task_text", "value": tasks},
199                "content": format!("- [ ] {}", tasks)
200            }));
201        }
202
203        if let Some(notes) = notes_content {
204            commands.push(serde_json::json!({
205                "target": "notes",
206                "command": "append_to_section",
207                "selector": {"type": "section", "value": "## Design Decisions"},
208                "content": notes
209            }));
210        }
211
212        UpdateSpecArgs {
213            project_name: project_name.to_string(),
214            spec_name: spec_name.to_string(),
215            commands: serde_json::to_string(&commands).unwrap(),
216        }
217    }
218
219    /// Create test arguments for delete_spec
220    pub fn delete_spec_args(&self, project_name: &str, spec_name: &str) -> DeleteSpecArgs {
221        DeleteSpecArgs {
222            project_name: project_name.to_string(),
223            spec_name: spec_name.to_string(),
224            confirm: "true".to_string(),
225        }
226    }
227
228    /// Create test arguments for install command
229    pub fn install_args(&self, target: &str) -> InstallArgs {
230        InstallArgs {
231            target: target.to_string(),
232            binary_path: Some(self.mock_binary_path()),
233            json: false,
234        }
235    }
236
237    /// Create test arguments for install command with JSON output
238    pub fn install_args_json(&self, target: &str) -> InstallArgs {
239        InstallArgs {
240            target: target.to_string(),
241            binary_path: Some(self.mock_binary_path()),
242            json: true,
243        }
244    }
245
246    /// Create test arguments for install command with explicit binary path
247    pub fn install_args_with_binary(&self, target: &str, binary_path: &str) -> InstallArgs {
248        InstallArgs {
249            target: target.to_string(),
250            binary_path: Some(binary_path.to_string()),
251            json: false,
252        }
253    }
254
255    /// Create test arguments for uninstall command
256    pub fn uninstall_args(&self, target: &str, remove_config: bool) -> UninstallArgs {
257        UninstallArgs {
258            target: target.to_string(),
259            remove_config,
260            json: false,
261        }
262    }
263
264    /// Create test arguments for uninstall command with JSON output
265    pub fn uninstall_args_json(&self, target: &str, remove_config: bool) -> UninstallArgs {
266        UninstallArgs {
267            target: target.to_string(),
268            remove_config,
269            json: true,
270        }
271    }
272
273    /// Create test arguments for status command
274    pub fn status_args(&self, target: Option<&str>, detailed: bool) -> StatusArgs {
275        StatusArgs {
276            target: target.map(|s| s.to_string()),
277            detailed,
278            json: false,
279        }
280    }
281
282    /// Parse install response JSON for testing
283    pub fn parse_install_response(&self, json_response: &str) -> anyhow::Result<InstallResponse> {
284        Ok(serde_json::from_str(json_response)?)
285    }
286
287    /// Parse uninstall response JSON for testing
288    pub fn parse_uninstall_response(
289        &self,
290        json_response: &str,
291    ) -> anyhow::Result<UninstallResponse> {
292        Ok(serde_json::from_str(json_response)?)
293    }
294
295    /// Execute install command and return parsed response for testing
296    pub async fn install_and_parse(&self, target: &str) -> anyhow::Result<InstallResponse> {
297        use crate::cli::commands::install;
298        let args = self.install_args_json(target);
299        let response_json = install::execute(args).await?;
300        self.parse_install_response(&response_json)
301    }
302
303    /// Execute uninstall command and return parsed response for testing
304    pub async fn uninstall_and_parse(
305        &self,
306        target: &str,
307        remove_config: bool,
308    ) -> anyhow::Result<UninstallResponse> {
309        use crate::cli::commands::uninstall;
310        let args = self.uninstall_args_json(target, remove_config);
311        let response_json = uninstall::execute(args).await?;
312        self.parse_uninstall_response(&response_json)
313    }
314
315    /// Execute install command with standard args and return parsed response for testing
316    /// This is a compatibility helper for tests that use the old pattern
317    pub async fn install_with_args(&self, args: InstallArgs) -> anyhow::Result<InstallResponse> {
318        use crate::cli::commands::install;
319        // Convert to JSON mode for parsing
320        let json_args = InstallArgs {
321            target: args.target,
322            binary_path: args.binary_path,
323            json: true,
324        };
325        let response_json = install::execute(json_args).await?;
326        self.parse_install_response(&response_json)
327    }
328
329    /// Execute uninstall command with standard args and return parsed response for testing
330    /// This is a compatibility helper for tests that use the old pattern
331    pub async fn uninstall_with_args(
332        &self,
333        args: UninstallArgs,
334    ) -> anyhow::Result<UninstallResponse> {
335        use crate::cli::commands::uninstall;
336        // Convert to JSON mode for parsing
337        let json_args = UninstallArgs {
338            target: args.target,
339            remove_config: args.remove_config,
340            json: true,
341        };
342        let response_json = uninstall::execute(json_args).await?;
343        self.parse_uninstall_response(&response_json)
344    }
345
346    /// Create test arguments for status command with JSON output for testing
347    pub fn status_args_json(&self, target: Option<&str>, detailed: bool) -> StatusArgs {
348        StatusArgs {
349            target: target.map(|s| s.to_string()),
350            detailed,
351            json: true,
352        }
353    }
354
355    /// Execute install command and return human-readable text output for testing
356    pub async fn install_text_output(&self, target: &str) -> anyhow::Result<String> {
357        use crate::cli::commands::install;
358        let args = self.install_args(target); // Uses json: false
359        install::execute(args).await
360    }
361
362    /// Execute uninstall command and return human-readable text output for testing
363    pub async fn uninstall_text_output(
364        &self,
365        target: &str,
366        remove_config: bool,
367    ) -> anyhow::Result<String> {
368        use crate::cli::commands::uninstall;
369        let args = self.uninstall_args(target, remove_config); // Uses json: false
370        uninstall::execute(args).await
371    }
372
373    /// Execute status command and return parsed structured response for testing
374    pub async fn get_status_response(
375        &self,
376        target: Option<&str>,
377        detailed: bool,
378    ) -> anyhow::Result<crate::types::responses::StatusResponse> {
379        use crate::cli::commands::status;
380
381        let status_args = self.status_args_json(target, detailed);
382        let json_output = status::execute(status_args).await?;
383        let response = serde_json::from_str(&json_output)?;
384        Ok(response)
385    }
386
387    /// Return a realistic binary path without creating actual file
388    /// Uses current executable for realistic testing
389    fn mock_binary_path(&self) -> String {
390        // Use current foundry binary for realistic testing
391        std::env::current_exe()
392            .unwrap_or_else(|_| std::path::PathBuf::from("/usr/local/bin/foundry"))
393            .to_string_lossy()
394            .to_string()
395    }
396
397    /// Get cursor config path within test environment
398    /// Returns the path where ~/.cursor/mcp.json would be created in the isolated test environment
399    pub fn cursor_config_path(&self) -> std::path::PathBuf {
400        self.temp_dir.path().join(".cursor").join("mcp.json")
401    }
402
403    /// Get cursor config directory within test environment
404    pub fn cursor_config_dir(&self) -> std::path::PathBuf {
405        self.temp_dir.path().join(".cursor")
406    }
407
408    /// Get claude code config path within test environment
409    pub fn claude_code_config_path(&self) -> std::path::PathBuf {
410        self.temp_dir.path().join(".claude.json")
411    }
412
413    /// Get Claude Code agents directory path within test environment
414    pub fn claude_agents_dir(&self) -> std::path::PathBuf {
415        self.temp_dir.path().join(".claude").join("agents")
416    }
417
418    /// Get Claude Code subagent file path within test environment
419    pub fn claude_subagent_path(&self) -> std::path::PathBuf {
420        self.claude_agents_dir().join("foundry-mcp-agent.md")
421    }
422
423    /// Get Cursor rules directory path within test environment
424    pub fn cursor_rules_dir(&self) -> std::path::PathBuf {
425        self.temp_dir.path().join(".cursor").join("rules")
426    }
427
428    /// Get Cursor rules file path within test environment
429    pub fn cursor_rules_path(&self) -> std::path::PathBuf {
430        self.cursor_rules_dir().join("foundry.mdc")
431    }
432
433    /// Get Claude commands directory path within test environment
434    pub fn claude_commands_dir(&self) -> std::path::PathBuf {
435        self.temp_dir
436            .path()
437            .join(".claude")
438            .join("commands")
439            .join("foundry")
440    }
441
442    /// Get Cursor commands directory path within test environment
443    pub fn cursor_commands_dir(&self) -> std::path::PathBuf {
444        self.cursor_config_dir().join("commands")
445    }
446
447    /// Create an invalid binary path for error testing
448    pub fn invalid_binary_path(&self) -> String {
449        "/definitely/does/not/exist/foundry".to_string()
450    }
451
452    /// Create a binary path that exists but is not executable (for platforms that check)
453    pub fn non_executable_binary_path(&self) -> String {
454        let binary_path = self.temp_dir.path().join("non-executable");
455        std::fs::write(&binary_path, b"not executable content").unwrap();
456        // Note: We still create this file since some tests might check file existence
457        // but execution permission validation happens at runtime, not install time
458        binary_path.to_string_lossy().to_string()
459    }
460
461    /// Create an existing cursor config with custom content for testing conflict scenarios
462    pub fn create_existing_cursor_config(&self, content: &str) -> Result<()> {
463        let config_dir = self.cursor_config_dir();
464        std::fs::create_dir_all(&config_dir)?;
465        std::fs::write(self.cursor_config_path(), content)?;
466        Ok(())
467    }
468
469    /// Verify that Cursor rules template was created with expected content
470    pub fn verify_cursor_rules_template(&self) -> Result<()> {
471        let rules_path = self.cursor_rules_path();
472        if !rules_path.exists() {
473            anyhow::bail!("Cursor rules file should exist after installation");
474        }
475
476        let rules_content = std::fs::read_to_string(&rules_path)?;
477
478        // Verify essential content sections
479        if !rules_content.contains("# Foundry MCP Usage Guide") {
480            anyhow::bail!("Rules should contain usage guide header");
481        }
482        if !rules_content.contains("create_project") || !rules_content.contains("update_spec") {
483            anyhow::bail!("Rules should reference Foundry MCP tools");
484        }
485        if !rules_content.contains("Content Agnostic") {
486            anyhow::bail!("Rules should contain core principles");
487        }
488
489        Ok(())
490    }
491
492    /// Verify that Claude subagent template was created with expected content
493    pub fn verify_claude_subagent_template(&self) -> Result<()> {
494        let subagent_path = self.claude_subagent_path();
495        if !subagent_path.exists() {
496            anyhow::bail!("Claude subagent file should exist after installation");
497        }
498
499        let subagent_content = std::fs::read_to_string(&subagent_path)?;
500
501        // Verify essential content sections
502        if !subagent_content.contains("---") {
503            anyhow::bail!("Subagent should contain YAML frontmatter");
504        }
505        if !subagent_content.contains("foundry-mcp-agent") {
506            anyhow::bail!("Subagent should contain agent name");
507        }
508        if !subagent_content.contains("mcp_foundry_") {
509            anyhow::bail!("Subagent should reference MCP tools");
510        }
511        if !subagent_content.contains("Content Agnostic") {
512            anyhow::bail!("Subagent should contain core principles");
513        }
514        if !subagent_content.contains("IMPORTANT: Append only adds to the END") {
515            anyhow::bail!("Subagent should contain critical append guidance");
516        }
517        if !subagent_content.contains("Content Creation Standards") {
518            anyhow::bail!("Subagent should contain content formatting guidelines");
519        }
520
521        Ok(())
522    }
523
524    /// Execute async test logic with proper environment isolation
525    /// Uses a simple approach with a dedicated runtime
526    pub fn with_env_async<F, Fut, T>(&self, f: F) -> T
527    where
528        F: FnOnce() -> Fut,
529        Fut: Future<Output = T>,
530    {
531        // Set additional environment variables for test isolation
532        let cursor_config_dir = self.cursor_config_dir().to_string_lossy().to_string();
533        let claude_config_dir = self
534            .temp_dir
535            .path()
536            .join(".claude")
537            .to_string_lossy()
538            .to_string();
539
540        // Set environment variables for the test
541        let _cursor_guard = env_var_guard("CURSOR_CONFIG_DIR", &cursor_config_dir);
542        let _claude_guard = env_var_guard("CLAUDE_CONFIG_DIR", &claude_config_dir);
543
544        // Always create a new single-threaded runtime for simplicity and isolation
545        let rt = tokio::runtime::Builder::new_current_thread()
546            .enable_all()
547            .build()
548            .expect("Failed to create tokio runtime for test - this should never happen");
549        rt.block_on(f())
550    }
551
552    /// Execute async test logic with PATH environment variable isolation
553    /// This is needed for tests that require mock binaries to be in PATH
554    pub fn with_env_and_path_async<F, Fut, T>(&self, f: F) -> T
555    where
556        F: FnOnce() -> Fut,
557        Fut: Future<Output = T>,
558    {
559        // Set additional environment variables for test isolation
560        let cursor_config_dir = self.cursor_config_dir().to_string_lossy().to_string();
561        let claude_config_dir = self
562            .temp_dir
563            .path()
564            .join(".claude")
565            .to_string_lossy()
566            .to_string();
567
568        // Create isolated PATH that includes our temp bin directory
569        let temp_bin_dir = self
570            .temp_dir
571            .path()
572            .join("bin")
573            .to_string_lossy()
574            .to_string();
575        // Use a minimal PATH that includes our temp bin directory
576        // This avoids reading the real PATH environment variable
577        let isolated_path = format!("{}:/usr/local/bin:/usr/bin:/bin", temp_bin_dir);
578
579        // Set environment variables for the test
580        let _cursor_guard = env_var_guard("CURSOR_CONFIG_DIR", &cursor_config_dir);
581        let _claude_guard = env_var_guard("CLAUDE_CONFIG_DIR", &claude_config_dir);
582        let _path_guard = env_var_guard("PATH", &isolated_path);
583
584        // Always create a new single-threaded runtime for simplicity and isolation
585        let rt = tokio::runtime::Builder::new_current_thread()
586            .enable_all()
587            .build()
588            .expect("Failed to create tokio runtime for test - this should never happen");
589        rt.block_on(f())
590    }
591
592    /// Create a mock binary file for testing
593    /// Returns the path to the created binary
594    pub fn create_mock_binary(&self, name: &str) -> Result<PathBuf> {
595        let binary_dir = self.temp_dir.path().join("bin");
596        std::fs::create_dir_all(&binary_dir)?;
597
598        let binary_path = binary_dir.join(name);
599        std::fs::write(&binary_path, "#!/bin/bash\necho 'Mock binary'")?;
600
601        // Make it executable (Unix systems)
602        #[cfg(unix)]
603        {
604            use std::os::unix::fs::PermissionsExt;
605            let mut perms = std::fs::metadata(&binary_path)?.permissions();
606            perms.set_mode(0o755);
607            std::fs::set_permissions(&binary_path, perms)?;
608        }
609
610        Ok(binary_path)
611    }
612
613    /// Create a mock claude command that handles the specific commands used by the installation process
614    /// Returns the path to the created binary
615    pub fn create_mock_claude_binary(&self) -> Result<PathBuf> {
616        let binary_dir = self.temp_dir.path().join("bin");
617        std::fs::create_dir_all(&binary_dir)?;
618
619        let binary_path = binary_dir.join("claude");
620
621        // Create a bash script that handles the specific claude commands
622        let script_content = r#"#!/bin/bash
623# Mock claude command for testing
624case "$1" in
625    "--version")
626        echo "claude version 1.0.0"
627        exit 0
628        ;;
629    "mcp")
630        case "$2" in
631            "add")
632                # Mock successful MCP server registration
633                echo "MCP server 'foundry' added successfully"
634                exit 0
635                ;;
636            "remove")
637                # Mock MCP server removal - fail if server doesn't exist
638                echo "No MCP server found with name: 'foundry'" >&2
639                exit 1
640                ;;
641            *)
642                echo "Unknown mcp command: $2"
643                exit 1
644                ;;
645        esac
646        ;;
647    *)
648        echo "Unknown command: $1"
649        exit 1
650        ;;
651esac
652"#;
653
654        std::fs::write(&binary_path, script_content)?;
655
656        // Make it executable (Unix systems)
657        #[cfg(unix)]
658        {
659            use std::os::unix::fs::PermissionsExt;
660            let mut perms = std::fs::metadata(&binary_path)?.permissions();
661            perms.set_mode(0o755);
662            std::fs::set_permissions(&binary_path, perms)?;
663        }
664
665        Ok(binary_path)
666    }
667
668    /// Create a cursor MCP configuration with the given server entries
669    /// Each entry is a tuple of (server_name, command_path)
670    pub fn create_cursor_config(&self, servers: &[(&str, &str)]) -> Result<()> {
671        let config_dir = self.cursor_config_dir();
672        std::fs::create_dir_all(&config_dir)?;
673
674        let mut config = serde_json::Map::new();
675        let mut servers_config = serde_json::Map::new();
676
677        // Always include mcpServers field, even if empty
678        for (name, command) in servers {
679            let mut server_config = serde_json::Map::new();
680            server_config.insert(
681                "command".to_string(),
682                serde_json::Value::String(command.to_string()),
683            );
684            server_config.insert("args".to_string(), serde_json::Value::Array(vec![]));
685
686            servers_config.insert(name.to_string(), serde_json::Value::Object(server_config));
687        }
688
689        config.insert(
690            "mcpServers".to_string(),
691            serde_json::Value::Object(servers_config),
692        );
693
694        let config_content = serde_json::to_string_pretty(&config)?;
695        std::fs::write(self.cursor_config_path(), config_content)?;
696
697        Ok(())
698    }
699
700    /// Create a test project for testing spec functionality
701    pub async fn create_test_project(&self, project_name: &str) -> Result<()> {
702        use crate::cli::commands::create_project;
703        let args = self.create_project_args(project_name);
704        create_project::execute(args).await?;
705        Ok(())
706    }
707
708    /// Create a test spec for testing spec functionality
709    pub async fn create_test_spec(
710        &self,
711        project_name: &str,
712        feature_name: &str,
713        spec_content: &str,
714    ) -> Result<()> {
715        use crate::cli::commands::create_spec;
716        let mut args = self.create_spec_args(project_name, feature_name);
717        args.spec = spec_content.to_string();
718        create_spec::execute(args).await?;
719        Ok(())
720    }
721}
722
723impl Drop for TestEnvironment {
724    fn drop(&mut self) {
725        // Restore original HOME environment variable
726        unsafe {
727            if let Some(original_home) = &self.original_home {
728                env::set_var("HOME", original_home);
729            } else {
730                env::remove_var("HOME");
731            }
732        }
733        // Lock is automatically released when _lock is dropped
734    }
735}