pub struct TestEnvironment {
pub temp_dir: TempDir,
home: PathBuf,
cursor_config: PathBuf,
claude_config: PathBuf,
bin: PathBuf,
}
impl TestEnvironment {
pub fn new() -> Result<Self> {
let temp_dir = TempDir::new()?;
let home = temp_dir.path().join("home");
let cursor_config = temp_dir.path().join(".cursor");
let claude_config = temp_dir.path().join(".claude");
let bin = temp_dir.path().join("bin");
fs::create_dir_all(&home)?;
fs::create_dir_all(&cursor_config)?;
fs::create_dir_all(&claude_config)?;
fs::create_dir_all(&bin)?;
Ok(TestEnvironment {
temp_dir,
home,
cursor_config,
claude_config,
bin,
})
}
pub fn foundry_dir(&self) -> PathBuf {
self.home.join(".foundry")
}
pub fn root(&self) -> &Path {
self.temp_dir.path()
}
pub fn join(&self, rel: impl AsRef<Path>) -> PathBuf {
self.temp_dir.path().join(rel)
}
pub fn bin_dir(&self) -> &Path {
&self.bin
}
pub fn write_file(&self, rel: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<()> {
let path = self.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
Ok(())
}
pub fn read_to_string(&self, rel: impl AsRef<Path>) -> Result<String> {
let path = self.join(rel);
fs::read_to_string(path).map_err(Into::into)
}
pub fn make_executable(&self, rel: impl AsRef<Path>, contents: &str) -> Result<PathBuf> {
let path = self.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms)?;
}
Ok(path)
}
fn base_vars(&self) -> Vec<(OsString, Option<OsString>)> {
let mut vars = Vec::new();
#[cfg(windows)]
vars.push((
OsString::from("USERPROFILE"),
Some(self.home.clone().into_os_string()),
));
#[cfg(not(windows))]
vars.push((
OsString::from("HOME"),
Some(self.home.clone().into_os_string()),
));
vars.push((
OsString::from("CURSOR_CONFIG_DIR"),
Some(self.cursor_config.clone().into_os_string()),
));
vars.push((
OsString::from("CLAUDE_CONFIG_DIR"),
Some(self.claude_config.clone().into_os_string()),
));
let orig_path = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = OsString::new();
new_path.push(self.bin.clone().into_os_string());
#[cfg(windows)]
new_path.push(";");
#[cfg(not(windows))]
new_path.push(":");
new_path.push(orig_path);
vars.push((OsString::from("PATH"), Some(new_path)));
vars
}
pub fn with_env<F, T>(&self, f: F) -> T
where
F: FnOnce() -> T,
{
temp_env::with_vars(self.base_vars(), f)
}
pub fn with_env_async<F, Fut, T>(&self, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
self.with_env(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime for test");
rt.block_on(f())
})
}
pub fn with_env_and_vars<F, T>(&self, extra: &[(OsString, Option<OsString>)], f: F) -> T
where
F: FnOnce() -> T,
{
let mut vars = self.base_vars();
vars.extend_from_slice(extra);
temp_env::with_vars(vars, f)
}
pub fn with_env_and_vars_async<F, Fut, T>(
&self,
extra: &[(OsString, Option<OsString>)],
f: F,
) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
self.with_env_and_vars(extra, || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime for test");
rt.block_on(f())
})
}
pub fn with_env_and_path_async<F, Fut, T>(&self, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
let current_path = std::env::var_os("PATH").unwrap_or_default();
let mut path_vec = vec![self.bin.clone().into_os_string()];
if !current_path.is_empty() {
#[cfg(windows)]
path_vec.push(OsString::from(";"));
#[cfg(not(windows))]
path_vec.push(OsString::from(":"));
path_vec.push(current_path);
}
let new_path = path_vec.into_iter().collect::<OsString>();
let extra_vars = &[(OsString::from("PATH"), Some(new_path))];
self.with_env_and_vars(extra_vars, || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime for test");
rt.block_on(f())
})
}
pub fn cursor_config_path(&self) -> PathBuf {
self.cursor_config.join("mcp.json")
}
pub fn cursor_config_dir(&self) -> PathBuf {
self.cursor_config.clone()
}
pub fn claude_code_config_path(&self) -> PathBuf {
self.home.join(".claude.json")
}
pub fn claude_config_dir(&self) -> PathBuf {
self.claude_config.clone()
}
pub fn claude_agents_dir(&self) -> PathBuf {
self.claude_config.join("agents")
}
pub fn claude_subagent_path(&self) -> PathBuf {
self.claude_agents_dir().join("foundry-mcp-agent.md")
}
pub fn cursor_rules_dir(&self) -> PathBuf {
self.cursor_config.join("rules")
}
pub fn cursor_rules_path(&self) -> PathBuf {
self.cursor_rules_dir().join("foundry.mdc")
}
pub fn claude_commands_dir(&self) -> PathBuf {
self.claude_config.join("commands").join("foundry")
}
pub fn cursor_commands_dir(&self) -> PathBuf {
self.cursor_config.join("commands")
}
pub fn create_cursor_config(&self, servers: &[(&str, &str)]) -> Result<()> {
fs::create_dir_all(&self.cursor_config)?;
let mut config = serde_json::Map::new();
let mut servers_config = serde_json::Map::new();
for (name, command) in servers {
let mut server_config = serde_json::Map::new();
server_config.insert(
"command".to_string(),
serde_json::Value::String(command.to_string()),
);
server_config.insert("args".to_string(), serde_json::Value::Array(vec![]));
servers_config.insert(name.to_string(), serde_json::Value::Object(server_config));
}
config.insert(
"mcpServers".to_string(),
serde_json::Value::Object(servers_config),
);
let config_content = serde_json::to_string_pretty(&config)?;
fs::write(self.cursor_config_path(), config_content)?;
Ok(())
}
pub fn create_existing_cursor_config(&self, content: &str) -> Result<()> {
fs::create_dir_all(&self.cursor_config)?;
fs::write(self.cursor_config_path(), content)?;
Ok(())
}
pub fn create_mock_binary(&self, name: &str) -> Result<PathBuf> {
fs::create_dir_all(&self.bin)?;
let binary_path = self.bin.join(name);
fs::write(&binary_path, "#!/bin/bash\necho 'Mock binary'")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&binary_path, perms)?;
}
Ok(binary_path)
}
pub fn create_mock_claude_binary(&self) -> Result<PathBuf> {
fs::create_dir_all(&self.bin)?;
let binary_path = self.bin.join("claude");
let script_content = r#"#!/bin/bash
# Mock claude command for testing
case "$1" in
"--version")
echo "claude version 1.0.0"
exit 0
;;
"mcp")
case "$2" in
"add")
# Mock successful MCP server registration
echo "MCP server 'foundry' added successfully"
exit 0
;;
"remove")
# Mock MCP server removal - fail if server doesn't exist
echo "No MCP server found with name: 'foundry'" >&2
exit 1
;;
*)
echo "Unknown mcp command: $2"
exit 1
;;
esac
;;
*)
echo "Unknown command: $1"
exit 1
;;
esac
"#;
fs::write(&binary_path, script_content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&binary_path, perms)?;
}
Ok(binary_path)
}
pub fn invalid_binary_path(&self) -> String {
"/definitely/does/not/exist/foundry".to_string()
}
pub fn non_executable_binary_path(&self) -> String {
let binary_path = self.temp_dir.path().join("non-executable");
fs::write(&binary_path, b"not executable content").unwrap();
binary_path.to_string_lossy().to_string()
}
pub fn verify_cursor_rules_template(&self) -> Result<()> {
let rules_path = self.cursor_rules_path();
if !rules_path.exists() {
anyhow::bail!("Cursor rules file should exist after installation");
}
let rules_content = fs::read_to_string(&rules_path)?;
if !rules_content.contains("# Foundry MCP Usage Guide") {
anyhow::bail!("Rules should contain usage guide header");
}
if !rules_content.contains("create_project") || !rules_content.contains("update_spec") {
anyhow::bail!("Rules should reference Foundry MCP tools");
}
if !rules_content.contains("Content Agnostic") {
anyhow::bail!("Rules should contain core principles");
}
Ok(())
}
pub fn verify_claude_subagent_template(&self) -> Result<()> {
let subagent_path = self.claude_subagent_path();
if !subagent_path.exists() {
anyhow::bail!("Claude subagent file should exist after installation");
}
let subagent_content = fs::read_to_string(&subagent_path)?;
if !subagent_content.contains("---") {
anyhow::bail!("Subagent should contain YAML frontmatter");
}
if !subagent_content.contains("foundry-mcp-agent") {
anyhow::bail!("Subagent should contain agent name");
}
if !subagent_content.contains("mcp_foundry_") {
anyhow::bail!("Subagent should reference MCP tools");
}
if !subagent_content.contains("Content Agnostic") {
anyhow::bail!("Subagent should contain core principles");
}
if !subagent_content.contains("IMPORTANT: Append only adds to the END") {
anyhow::bail!("Subagent should contain critical append guidance");
}
if !subagent_content.contains("Content Creation Standards") {
anyhow::bail!("Subagent should contain content formatting guidelines");
}
Ok(())
}
}
impl Default for TestEnvironment {
fn default() -> Self {
Self::new().expect("Failed to create test environment")
}
}