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: "# 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 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 ) -> 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 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 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 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 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 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 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 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 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 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 pub fn parse_install_response(&self, json_response: &str) -> anyhow::Result<InstallResponse> {
284 Ok(serde_json::from_str(json_response)?)
285 }
286
287 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 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 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 pub async fn install_with_args(&self, args: InstallArgs) -> anyhow::Result<InstallResponse> {
318 use crate::cli::commands::install;
319 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 pub async fn uninstall_with_args(
332 &self,
333 args: UninstallArgs,
334 ) -> anyhow::Result<UninstallResponse> {
335 use crate::cli::commands::uninstall;
336 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 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 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); install::execute(args).await
360 }
361
362 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); uninstall::execute(args).await
371 }
372
373 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 fn mock_binary_path(&self) -> String {
390 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 pub fn cursor_config_path(&self) -> std::path::PathBuf {
400 self.temp_dir.path().join(".cursor").join("mcp.json")
401 }
402
403 pub fn cursor_config_dir(&self) -> std::path::PathBuf {
405 self.temp_dir.path().join(".cursor")
406 }
407
408 pub fn claude_code_config_path(&self) -> std::path::PathBuf {
410 self.temp_dir.path().join(".claude.json")
411 }
412
413 pub fn claude_agents_dir(&self) -> std::path::PathBuf {
415 self.temp_dir.path().join(".claude").join("agents")
416 }
417
418 pub fn claude_subagent_path(&self) -> std::path::PathBuf {
420 self.claude_agents_dir().join("foundry-mcp-agent.md")
421 }
422
423 pub fn cursor_rules_dir(&self) -> std::path::PathBuf {
425 self.temp_dir.path().join(".cursor").join("rules")
426 }
427
428 pub fn cursor_rules_path(&self) -> std::path::PathBuf {
430 self.cursor_rules_dir().join("foundry.mdc")
431 }
432
433 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 pub fn cursor_commands_dir(&self) -> std::path::PathBuf {
444 self.cursor_config_dir().join("commands")
445 }
446
447 pub fn invalid_binary_path(&self) -> String {
449 "/definitely/does/not/exist/foundry".to_string()
450 }
451
452 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 binary_path.to_string_lossy().to_string()
459 }
460
461 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 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 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 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 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 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 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 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 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 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 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 let temp_bin_dir = self
570 .temp_dir
571 .path()
572 .join("bin")
573 .to_string_lossy()
574 .to_string();
575 let isolated_path = format!("{}:/usr/local/bin:/usr/bin:/bin", temp_bin_dir);
578
579 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 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 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 #[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_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 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 #[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 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 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 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 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 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 }
735}