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: "This specification defines a comprehensive feature implementation that includes detailed requirements, functional specifications, and behavioral expectations. The 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 include important considerations for security, performance, and maintainability. Special attention should be paid to error handling and edge cases. Consider 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        operation: &str,
122    ) -> UpdateSpecArgs {
123        let content = "Updated content for testing that meets the minimum length requirements and provides comprehensive information for the specification update.".to_string();
124
125        match file_type {
126            "spec" => UpdateSpecArgs {
127                project_name: project_name.to_string(),
128                spec_name: spec_name.to_string(),
129                spec: Some(content),
130                tasks: None,
131                notes: None,
132                operation: operation.to_string(),
133                context_patch: None,
134            },
135            "task-list" | "tasks" => UpdateSpecArgs {
136                project_name: project_name.to_string(),
137                spec_name: spec_name.to_string(),
138                spec: None,
139                tasks: Some(content),
140                notes: None,
141                operation: operation.to_string(),
142                context_patch: None,
143            },
144            "notes" => UpdateSpecArgs {
145                project_name: project_name.to_string(),
146                spec_name: spec_name.to_string(),
147                spec: None,
148                tasks: None,
149                notes: Some(content),
150                operation: operation.to_string(),
151                context_patch: None,
152            },
153            _ => panic!("Invalid file_type: {}", file_type),
154        }
155    }
156
157    /// Create test arguments for update_spec with multiple file updates
158    pub fn update_spec_args_multi(
159        &self,
160        project_name: &str,
161        spec_name: &str,
162        operation: &str,
163        spec_content: Option<&str>,
164        tasks_content: Option<&str>,
165        notes_content: Option<&str>,
166    ) -> UpdateSpecArgs {
167        UpdateSpecArgs {
168            project_name: project_name.to_string(),
169            spec_name: spec_name.to_string(),
170            spec: spec_content.map(|s| s.to_string()),
171            tasks: tasks_content.map(|s| s.to_string()),
172            notes: notes_content.map(|s| s.to_string()),
173            operation: operation.to_string(),
174            context_patch: None,
175        }
176    }
177
178    /// Create test arguments for delete_spec
179    pub fn delete_spec_args(&self, project_name: &str, spec_name: &str) -> DeleteSpecArgs {
180        DeleteSpecArgs {
181            project_name: project_name.to_string(),
182            spec_name: spec_name.to_string(),
183            confirm: "true".to_string(),
184        }
185    }
186
187    /// Create test arguments for install command
188    pub fn install_args(&self, target: &str) -> InstallArgs {
189        InstallArgs {
190            target: target.to_string(),
191            binary_path: Some(self.mock_binary_path()),
192            json: false,
193        }
194    }
195
196    /// Create test arguments for install command with JSON output
197    pub fn install_args_json(&self, target: &str) -> InstallArgs {
198        InstallArgs {
199            target: target.to_string(),
200            binary_path: Some(self.mock_binary_path()),
201            json: true,
202        }
203    }
204
205    /// Create test arguments for install command with explicit binary path
206    pub fn install_args_with_binary(&self, target: &str, binary_path: &str) -> InstallArgs {
207        InstallArgs {
208            target: target.to_string(),
209            binary_path: Some(binary_path.to_string()),
210            json: false,
211        }
212    }
213
214    /// Create test arguments for uninstall command
215    pub fn uninstall_args(&self, target: &str, remove_config: bool) -> UninstallArgs {
216        UninstallArgs {
217            target: target.to_string(),
218            remove_config,
219            json: false,
220        }
221    }
222
223    /// Create test arguments for uninstall command with JSON output
224    pub fn uninstall_args_json(&self, target: &str, remove_config: bool) -> UninstallArgs {
225        UninstallArgs {
226            target: target.to_string(),
227            remove_config,
228            json: true,
229        }
230    }
231
232    /// Create test arguments for status command
233    pub fn status_args(&self, target: Option<&str>, detailed: bool) -> StatusArgs {
234        StatusArgs {
235            target: target.map(|s| s.to_string()),
236            detailed,
237            json: false,
238        }
239    }
240
241    /// Parse install response JSON for testing
242    pub fn parse_install_response(&self, json_response: &str) -> anyhow::Result<InstallResponse> {
243        Ok(serde_json::from_str(json_response)?)
244    }
245
246    /// Parse uninstall response JSON for testing
247    pub fn parse_uninstall_response(
248        &self,
249        json_response: &str,
250    ) -> anyhow::Result<UninstallResponse> {
251        Ok(serde_json::from_str(json_response)?)
252    }
253
254    /// Execute install command and return parsed response for testing
255    pub async fn install_and_parse(&self, target: &str) -> anyhow::Result<InstallResponse> {
256        use crate::cli::commands::install;
257        let args = self.install_args_json(target);
258        let response_json = install::execute(args).await?;
259        self.parse_install_response(&response_json)
260    }
261
262    /// Execute uninstall command and return parsed response for testing
263    pub async fn uninstall_and_parse(
264        &self,
265        target: &str,
266        remove_config: bool,
267    ) -> anyhow::Result<UninstallResponse> {
268        use crate::cli::commands::uninstall;
269        let args = self.uninstall_args_json(target, remove_config);
270        let response_json = uninstall::execute(args).await?;
271        self.parse_uninstall_response(&response_json)
272    }
273
274    /// Execute install command with standard args and return parsed response for testing
275    /// This is a compatibility helper for tests that use the old pattern
276    pub async fn install_with_args(&self, args: InstallArgs) -> anyhow::Result<InstallResponse> {
277        use crate::cli::commands::install;
278        // Convert to JSON mode for parsing
279        let json_args = InstallArgs {
280            target: args.target,
281            binary_path: args.binary_path,
282            json: true,
283        };
284        let response_json = install::execute(json_args).await?;
285        self.parse_install_response(&response_json)
286    }
287
288    /// Execute uninstall command with standard args and return parsed response for testing
289    /// This is a compatibility helper for tests that use the old pattern
290    pub async fn uninstall_with_args(
291        &self,
292        args: UninstallArgs,
293    ) -> anyhow::Result<UninstallResponse> {
294        use crate::cli::commands::uninstall;
295        // Convert to JSON mode for parsing
296        let json_args = UninstallArgs {
297            target: args.target,
298            remove_config: args.remove_config,
299            json: true,
300        };
301        let response_json = uninstall::execute(json_args).await?;
302        self.parse_uninstall_response(&response_json)
303    }
304
305    /// Create test arguments for status command with JSON output for testing
306    pub fn status_args_json(&self, target: Option<&str>, detailed: bool) -> StatusArgs {
307        StatusArgs {
308            target: target.map(|s| s.to_string()),
309            detailed,
310            json: true,
311        }
312    }
313
314    /// Execute install command and return human-readable text output for testing
315    pub async fn install_text_output(&self, target: &str) -> anyhow::Result<String> {
316        use crate::cli::commands::install;
317        let args = self.install_args(target); // Uses json: false
318        install::execute(args).await
319    }
320
321    /// Execute uninstall command and return human-readable text output for testing
322    pub async fn uninstall_text_output(
323        &self,
324        target: &str,
325        remove_config: bool,
326    ) -> anyhow::Result<String> {
327        use crate::cli::commands::uninstall;
328        let args = self.uninstall_args(target, remove_config); // Uses json: false
329        uninstall::execute(args).await
330    }
331
332    /// Execute status command and return parsed structured response for testing
333    pub async fn get_status_response(
334        &self,
335        target: Option<&str>,
336        detailed: bool,
337    ) -> anyhow::Result<crate::types::responses::StatusResponse> {
338        use crate::cli::commands::status;
339
340        let status_args = self.status_args_json(target, detailed);
341        let json_output = status::execute(status_args).await?;
342        let response = serde_json::from_str(&json_output)?;
343        Ok(response)
344    }
345
346    /// Return a realistic binary path without creating actual file
347    /// Uses current executable for realistic testing
348    fn mock_binary_path(&self) -> String {
349        // Use current foundry binary for realistic testing
350        std::env::current_exe()
351            .unwrap_or_else(|_| std::path::PathBuf::from("/usr/local/bin/foundry"))
352            .to_string_lossy()
353            .to_string()
354    }
355
356    /// Get cursor config path within test environment
357    /// Returns the path where ~/.cursor/mcp.json would be created in the isolated test environment
358    pub fn cursor_config_path(&self) -> std::path::PathBuf {
359        self.temp_dir.path().join(".cursor").join("mcp.json")
360    }
361
362    /// Get cursor config directory within test environment
363    pub fn cursor_config_dir(&self) -> std::path::PathBuf {
364        self.temp_dir.path().join(".cursor")
365    }
366
367    /// Get claude code config path within test environment
368    pub fn claude_code_config_path(&self) -> std::path::PathBuf {
369        self.temp_dir.path().join(".claude.json")
370    }
371
372    /// Get Claude Code agents directory path within test environment
373    pub fn claude_agents_dir(&self) -> std::path::PathBuf {
374        self.temp_dir.path().join(".claude").join("agents")
375    }
376
377    /// Get Claude Code subagent file path within test environment
378    pub fn claude_subagent_path(&self) -> std::path::PathBuf {
379        self.claude_agents_dir().join("foundry-mcp-agent.md")
380    }
381
382    /// Get Cursor rules directory path within test environment
383    pub fn cursor_rules_dir(&self) -> std::path::PathBuf {
384        self.temp_dir.path().join(".cursor").join("rules")
385    }
386
387    /// Get Cursor rules file path within test environment
388    pub fn cursor_rules_path(&self) -> std::path::PathBuf {
389        self.cursor_rules_dir().join("foundry.mdc")
390    }
391
392    /// Create an invalid binary path for error testing
393    pub fn invalid_binary_path(&self) -> String {
394        "/definitely/does/not/exist/foundry".to_string()
395    }
396
397    /// Create a binary path that exists but is not executable (for platforms that check)
398    pub fn non_executable_binary_path(&self) -> String {
399        let binary_path = self.temp_dir.path().join("non-executable");
400        std::fs::write(&binary_path, b"not executable content").unwrap();
401        // Note: We still create this file since some tests might check file existence
402        // but execution permission validation happens at runtime, not install time
403        binary_path.to_string_lossy().to_string()
404    }
405
406    /// Create an existing cursor config with custom content for testing conflict scenarios
407    pub fn create_existing_cursor_config(&self, content: &str) -> Result<()> {
408        let config_dir = self.cursor_config_dir();
409        std::fs::create_dir_all(&config_dir)?;
410        std::fs::write(self.cursor_config_path(), content)?;
411        Ok(())
412    }
413
414    /// Verify that Cursor rules template was created with expected content
415    pub fn verify_cursor_rules_template(&self) -> Result<()> {
416        let rules_path = self.cursor_rules_path();
417        if !rules_path.exists() {
418            anyhow::bail!("Cursor rules file should exist after installation");
419        }
420
421        let rules_content = std::fs::read_to_string(&rules_path)?;
422
423        // Verify essential content sections
424        if !rules_content.contains("# Foundry MCP Usage Guide") {
425            anyhow::bail!("Rules should contain usage guide header");
426        }
427        if !rules_content.contains("mcp_foundry_") {
428            anyhow::bail!("Rules should reference Foundry MCP tools");
429        }
430        if !rules_content.contains("Content Agnostic") {
431            anyhow::bail!("Rules should contain core principles");
432        }
433
434        Ok(())
435    }
436
437    /// Verify that Claude subagent template was created with expected content
438    pub fn verify_claude_subagent_template(&self) -> Result<()> {
439        let subagent_path = self.claude_subagent_path();
440        if !subagent_path.exists() {
441            anyhow::bail!("Claude subagent file should exist after installation");
442        }
443
444        let subagent_content = std::fs::read_to_string(&subagent_path)?;
445
446        // Verify essential content sections
447        if !subagent_content.contains("---") {
448            anyhow::bail!("Subagent should contain YAML frontmatter");
449        }
450        if !subagent_content.contains("foundry-mcp-agent") {
451            anyhow::bail!("Subagent should contain agent name");
452        }
453        if !subagent_content.contains("mcp_foundry_") {
454            anyhow::bail!("Subagent should reference MCP tools");
455        }
456        if !subagent_content.contains("Content Agnostic") {
457            anyhow::bail!("Subagent should contain core principles");
458        }
459        if !subagent_content.contains("IMPORTANT: Append only adds to the END") {
460            anyhow::bail!("Subagent should contain critical append guidance");
461        }
462        if !subagent_content.contains("Content Creation Standards") {
463            anyhow::bail!("Subagent should contain content formatting guidelines");
464        }
465
466        Ok(())
467    }
468
469    /// Execute async test logic with proper environment isolation
470    /// Uses a simple approach with a dedicated runtime
471    pub fn with_env_async<F, Fut, T>(&self, f: F) -> T
472    where
473        F: FnOnce() -> Fut,
474        Fut: Future<Output = T>,
475    {
476        // Set additional environment variables for test isolation
477        let cursor_config_dir = self.cursor_config_dir().to_string_lossy().to_string();
478        let claude_config_dir = self
479            .temp_dir
480            .path()
481            .join(".claude")
482            .to_string_lossy()
483            .to_string();
484
485        // Set environment variables for the test
486        let _cursor_guard = env_var_guard("CURSOR_CONFIG_DIR", &cursor_config_dir);
487        let _claude_guard = env_var_guard("CLAUDE_CONFIG_DIR", &claude_config_dir);
488
489        // Always create a new single-threaded runtime for simplicity and isolation
490        let rt = tokio::runtime::Builder::new_current_thread()
491            .enable_all()
492            .build()
493            .expect("Failed to create tokio runtime for test - this should never happen");
494        rt.block_on(f())
495    }
496
497    /// Execute async test logic with PATH environment variable isolation
498    /// This is needed for tests that require mock binaries to be in PATH
499    pub fn with_env_and_path_async<F, Fut, T>(&self, f: F) -> T
500    where
501        F: FnOnce() -> Fut,
502        Fut: Future<Output = T>,
503    {
504        // Set additional environment variables for test isolation
505        let cursor_config_dir = self.cursor_config_dir().to_string_lossy().to_string();
506        let claude_config_dir = self
507            .temp_dir
508            .path()
509            .join(".claude")
510            .to_string_lossy()
511            .to_string();
512
513        // Create isolated PATH that includes our temp bin directory
514        let temp_bin_dir = self
515            .temp_dir
516            .path()
517            .join("bin")
518            .to_string_lossy()
519            .to_string();
520        // Use a minimal PATH that includes our temp bin directory
521        // This avoids reading the real PATH environment variable
522        let isolated_path = format!("{}:/usr/local/bin:/usr/bin:/bin", temp_bin_dir);
523
524        // Set environment variables for the test
525        let _cursor_guard = env_var_guard("CURSOR_CONFIG_DIR", &cursor_config_dir);
526        let _claude_guard = env_var_guard("CLAUDE_CONFIG_DIR", &claude_config_dir);
527        let _path_guard = env_var_guard("PATH", &isolated_path);
528
529        // Always create a new single-threaded runtime for simplicity and isolation
530        let rt = tokio::runtime::Builder::new_current_thread()
531            .enable_all()
532            .build()
533            .expect("Failed to create tokio runtime for test - this should never happen");
534        rt.block_on(f())
535    }
536
537    /// Create a mock binary file for testing
538    /// Returns the path to the created binary
539    pub fn create_mock_binary(&self, name: &str) -> Result<PathBuf> {
540        let binary_dir = self.temp_dir.path().join("bin");
541        std::fs::create_dir_all(&binary_dir)?;
542
543        let binary_path = binary_dir.join(name);
544        std::fs::write(&binary_path, "#!/bin/bash\necho 'Mock binary'")?;
545
546        // Make it executable (Unix systems)
547        #[cfg(unix)]
548        {
549            use std::os::unix::fs::PermissionsExt;
550            let mut perms = std::fs::metadata(&binary_path)?.permissions();
551            perms.set_mode(0o755);
552            std::fs::set_permissions(&binary_path, perms)?;
553        }
554
555        Ok(binary_path)
556    }
557
558    /// Create a mock claude command that handles the specific commands used by the installation process
559    /// Returns the path to the created binary
560    pub fn create_mock_claude_binary(&self) -> Result<PathBuf> {
561        let binary_dir = self.temp_dir.path().join("bin");
562        std::fs::create_dir_all(&binary_dir)?;
563
564        let binary_path = binary_dir.join("claude");
565
566        // Create a bash script that handles the specific claude commands
567        let script_content = r#"#!/bin/bash
568# Mock claude command for testing
569case "$1" in
570    "--version")
571        echo "claude version 1.0.0"
572        exit 0
573        ;;
574    "mcp")
575        case "$2" in
576            "add")
577                # Mock successful MCP server registration
578                echo "MCP server 'foundry' added successfully"
579                exit 0
580                ;;
581            "remove")
582                # Mock MCP server removal - fail if server doesn't exist
583                echo "No MCP server found with name: 'foundry'" >&2
584                exit 1
585                ;;
586            *)
587                echo "Unknown mcp command: $2"
588                exit 1
589                ;;
590        esac
591        ;;
592    *)
593        echo "Unknown command: $1"
594        exit 1
595        ;;
596esac
597"#;
598
599        std::fs::write(&binary_path, script_content)?;
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 cursor MCP configuration with the given server entries
614    /// Each entry is a tuple of (server_name, command_path)
615    pub fn create_cursor_config(&self, servers: &[(&str, &str)]) -> Result<()> {
616        let config_dir = self.cursor_config_dir();
617        std::fs::create_dir_all(&config_dir)?;
618
619        let mut config = serde_json::Map::new();
620        let mut servers_config = serde_json::Map::new();
621
622        // Always include mcpServers field, even if empty
623        for (name, command) in servers {
624            let mut server_config = serde_json::Map::new();
625            server_config.insert(
626                "command".to_string(),
627                serde_json::Value::String(command.to_string()),
628            );
629            server_config.insert("args".to_string(), serde_json::Value::Array(vec![]));
630
631            servers_config.insert(name.to_string(), serde_json::Value::Object(server_config));
632        }
633
634        config.insert(
635            "mcpServers".to_string(),
636            serde_json::Value::Object(servers_config),
637        );
638
639        let config_content = serde_json::to_string_pretty(&config)?;
640        std::fs::write(self.cursor_config_path(), config_content)?;
641
642        Ok(())
643    }
644}
645
646impl Drop for TestEnvironment {
647    fn drop(&mut self) {
648        // Restore original HOME environment variable
649        unsafe {
650            if let Some(original_home) = &self.original_home {
651                env::set_var("HOME", original_home);
652            } else {
653                env::remove_var("HOME");
654            }
655        }
656        // Lock is automatically released when _lock is dropped
657    }
658}