1use 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
15static TEST_MUTEX: Mutex<()> = Mutex::new(());
17
18pub 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
36pub 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
48pub struct TestEnvironment {
51 pub temp_dir: TempDir,
52 pub original_home: Option<String>,
53 _lock: MutexGuard<'static, ()>, }
55
56impl TestEnvironment {
57 pub fn new() -> Result<Self> {
60 let lock = TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
63 poisoned.into_inner()
65 });
66
67 let temp_dir = TempDir::new()?;
68 let original_home = env::var("HOME").ok();
69
70 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 pub fn foundry_dir(&self) -> std::path::PathBuf {
84 self.temp_dir.path().join(".foundry")
85 }
86
87 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 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 pub fn load_project_args(&self, project_name: &str) -> LoadProjectArgs {
110 LoadProjectArgs {
111 project_name: project_name.to_string(),
112 }
113 }
114
115 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 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 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 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 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 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 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 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 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 pub fn parse_install_response(&self, json_response: &str) -> anyhow::Result<InstallResponse> {
243 Ok(serde_json::from_str(json_response)?)
244 }
245
246 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 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 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 pub async fn install_with_args(&self, args: InstallArgs) -> anyhow::Result<InstallResponse> {
277 use crate::cli::commands::install;
278 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 pub async fn uninstall_with_args(
291 &self,
292 args: UninstallArgs,
293 ) -> anyhow::Result<UninstallResponse> {
294 use crate::cli::commands::uninstall;
295 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 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 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); install::execute(args).await
319 }
320
321 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); uninstall::execute(args).await
330 }
331
332 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 fn mock_binary_path(&self) -> String {
349 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 pub fn cursor_config_path(&self) -> std::path::PathBuf {
359 self.temp_dir.path().join(".cursor").join("mcp.json")
360 }
361
362 pub fn cursor_config_dir(&self) -> std::path::PathBuf {
364 self.temp_dir.path().join(".cursor")
365 }
366
367 pub fn claude_code_config_path(&self) -> std::path::PathBuf {
369 self.temp_dir.path().join(".claude.json")
370 }
371
372 pub fn claude_agents_dir(&self) -> std::path::PathBuf {
374 self.temp_dir.path().join(".claude").join("agents")
375 }
376
377 pub fn claude_subagent_path(&self) -> std::path::PathBuf {
379 self.claude_agents_dir().join("foundry-mcp-agent.md")
380 }
381
382 pub fn cursor_rules_dir(&self) -> std::path::PathBuf {
384 self.temp_dir.path().join(".cursor").join("rules")
385 }
386
387 pub fn cursor_rules_path(&self) -> std::path::PathBuf {
389 self.cursor_rules_dir().join("foundry.mdc")
390 }
391
392 pub fn invalid_binary_path(&self) -> String {
394 "/definitely/does/not/exist/foundry".to_string()
395 }
396
397 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 binary_path.to_string_lossy().to_string()
404 }
405
406 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 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 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 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 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 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 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 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 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 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 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 let temp_bin_dir = self
515 .temp_dir
516 .path()
517 .join("bin")
518 .to_string_lossy()
519 .to_string();
520 let isolated_path = format!("{}:/usr/local/bin:/usr/bin:/bin", temp_bin_dir);
523
524 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 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 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 #[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 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 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 #[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 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 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 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 }
658}