use anyhow::{Context, Result, anyhow};
use clap::{Args, Subcommand};
use colored::Colorize;
use crate::core::ResourceType;
use crate::lockfile::LockFile;
use crate::manifest::{Manifest, ResourceDependency, find_manifest_with_optional};
use crate::utils::fs::atomic_write;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Args)]
pub struct RemoveCommand {
#[command(subcommand)]
command: RemoveSubcommand,
}
#[derive(Subcommand)]
enum RemoveSubcommand {
Source {
name: String,
#[arg(long)]
force: bool,
},
#[command(subcommand)]
Dep(RemoveDependencySubcommand),
}
#[derive(Subcommand)]
enum RemoveDependencySubcommand {
Agent {
name: String,
},
Snippet {
name: String,
},
Command {
name: String,
},
McpServer {
name: String,
},
Script {
name: String,
},
Hook {
name: String,
},
}
const fn get_dependencies_for_type(
manifest: &Manifest,
resource_type: ResourceType,
) -> &HashMap<String, ResourceDependency> {
match resource_type {
ResourceType::Agent => &manifest.agents,
ResourceType::Snippet => &manifest.snippets,
ResourceType::Command => &manifest.commands,
ResourceType::McpServer => &manifest.mcp_servers,
ResourceType::Script => &manifest.scripts,
ResourceType::Hook => &manifest.hooks,
}
}
const fn get_dependencies_for_type_mut(
manifest: &mut Manifest,
resource_type: ResourceType,
) -> &mut HashMap<String, ResourceDependency> {
match resource_type {
ResourceType::Agent => &mut manifest.agents,
ResourceType::Snippet => &mut manifest.snippets,
ResourceType::Command => &mut manifest.commands,
ResourceType::McpServer => &mut manifest.mcp_servers,
ResourceType::Script => &mut manifest.scripts,
ResourceType::Hook => &mut manifest.hooks,
}
}
fn get_installed_path_from_lockfile(
lockfile: &LockFile,
name: &str,
resource_type: ResourceType,
project_root: &std::path::Path,
manifest: &Manifest,
) -> Option<std::path::PathBuf> {
match resource_type {
ResourceType::Agent => lockfile
.agents
.iter()
.find(|a| a.name == name)
.map(|a| project_root.join(&a.installed_at)),
ResourceType::Snippet => lockfile
.snippets
.iter()
.find(|s| s.name == name)
.map(|s| project_root.join(&s.installed_at)),
ResourceType::Command => lockfile
.commands
.iter()
.find(|c| c.name == name)
.map(|c| project_root.join(&c.installed_at)),
ResourceType::McpServer => {
#[allow(deprecated)]
{
Some(project_root.join(&manifest.target.mcp_servers).join(format!("{name}.json")))
}
}
ResourceType::Script => lockfile
.scripts
.iter()
.find(|s| s.name == name)
.map(|s| project_root.join(&s.installed_at)),
ResourceType::Hook => lockfile
.hooks
.iter()
.find(|h| h.name == name)
.map(|h| project_root.join(&h.installed_at)),
}
}
fn remove_from_lockfile(lockfile: &mut LockFile, name: &str, resource_type: ResourceType) {
match resource_type {
ResourceType::Agent => lockfile.agents.retain(|a| a.name != name),
ResourceType::Snippet => lockfile.snippets.retain(|s| s.name != name),
ResourceType::Command => lockfile.commands.retain(|c| c.name != name),
ResourceType::McpServer => lockfile.mcp_servers.retain(|m| m.name != name),
ResourceType::Script => lockfile.scripts.retain(|s| s.name != name),
ResourceType::Hook => lockfile.hooks.retain(|h| h.name != name),
}
}
impl RemoveCommand {
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
match self.command {
RemoveSubcommand::Source {
name,
force,
} => remove_source_with_manifest_path(&name, force, manifest_path).await,
RemoveSubcommand::Dep(dep_command) => match dep_command {
RemoveDependencySubcommand::Agent {
name,
} => remove_dependency_with_manifest_path(&name, "agent", manifest_path).await,
RemoveDependencySubcommand::Snippet {
name,
} => remove_dependency_with_manifest_path(&name, "snippet", manifest_path).await,
RemoveDependencySubcommand::Command {
name,
} => remove_dependency_with_manifest_path(&name, "command", manifest_path).await,
RemoveDependencySubcommand::McpServer {
name,
} => remove_dependency_with_manifest_path(&name, "mcp-server", manifest_path).await,
RemoveDependencySubcommand::Script {
name,
} => remove_dependency_with_manifest_path(&name, "script", manifest_path).await,
RemoveDependencySubcommand::Hook {
name,
} => remove_dependency_with_manifest_path(&name, "hook", manifest_path).await,
},
}
}
}
async fn remove_source_with_manifest_path(
name: &str,
force: bool,
manifest_path: Option<PathBuf>,
) -> Result<()> {
let manifest_path = find_manifest_with_optional(manifest_path)?;
let mut manifest = Manifest::load(&manifest_path)?;
if !manifest.sources.contains_key(name) {
return Err(anyhow!("Source '{name}' not found in manifest"));
}
if !force {
let mut used_by = Vec::new();
for resource_type in ResourceType::all() {
let dependencies = get_dependencies_for_type(&manifest, *resource_type);
for (dep_name, dep) in dependencies {
if dep.get_source() == Some(name) {
used_by.push(format!("{resource_type} '{dep_name}'"));
}
}
}
if !used_by.is_empty() {
return Err(anyhow!(
"Source '{}' is still being used by: {}. Use --force to remove anyway",
name,
used_by.join(", ")
));
}
}
manifest.sources.remove(name);
atomic_write(&manifest_path, toml::to_string_pretty(&manifest)?.as_bytes())?;
let lockfile_path = manifest_path.parent().unwrap().join("agpm.lock");
if lockfile_path.exists() {
let mut lockfile = LockFile::load(&lockfile_path)?;
let project_root = manifest_path.parent().unwrap();
let agents_to_remove: Vec<String> = lockfile
.agents
.iter()
.filter(|a| a.source.as_deref() == Some(name))
.map(|a| a.installed_at.clone())
.collect();
let snippets_to_remove: Vec<String> = lockfile
.snippets
.iter()
.filter(|s| s.source.as_deref() == Some(name))
.map(|s| s.installed_at.clone())
.collect();
let commands_to_remove: Vec<String> = lockfile
.commands
.iter()
.filter(|c| c.source.as_deref() == Some(name))
.map(|c| c.installed_at.clone())
.collect();
for path_str in agents_to_remove
.iter()
.chain(snippets_to_remove.iter())
.chain(commands_to_remove.iter())
{
let path = project_root.join(path_str);
if path.exists() {
tokio::fs::remove_file(&path).await.with_context(|| {
format!("Failed to remove installed file: {}", path.display())
})?;
}
}
lockfile.sources.retain(|s| s.name != name);
lockfile.agents.retain(|a| a.source.as_deref() != Some(name));
lockfile.snippets.retain(|s| s.source.as_deref() != Some(name));
lockfile.commands.retain(|c| c.source.as_deref() != Some(name));
lockfile.mcp_servers.retain(|m| m.source.as_deref() != Some(name));
lockfile.scripts.retain(|s| s.source.as_deref() != Some(name));
lockfile.hooks.retain(|h| h.source.as_deref() != Some(name));
lockfile.save(&lockfile_path)?;
}
println!("{}", format!("Removed source '{name}'").green());
Ok(())
}
async fn remove_dependency_with_manifest_path(
name: &str,
dep_type: &str,
manifest_path: Option<PathBuf>,
) -> Result<()> {
let manifest_path = find_manifest_with_optional(manifest_path)?;
let mut manifest = Manifest::load(&manifest_path)?;
let resource_type: ResourceType =
dep_type.parse().map_err(|_| anyhow!("Invalid dependency type: {dep_type}"))?;
let dependencies = get_dependencies_for_type_mut(&mut manifest, resource_type);
if !dependencies.contains_key(name) {
let type_display = dep_type.replace('-', " ");
return Err(anyhow!(
"{} '{}' not found in manifest",
type_display.chars().next().unwrap().to_uppercase().collect::<String>()
+ &type_display[1..],
name
));
}
let removed = dependencies.remove(name).is_some();
if !removed {
return Err(anyhow!("{} '{}' not found in manifest", dep_type.replace('-', " "), name));
}
atomic_write(&manifest_path, toml::to_string_pretty(&manifest)?.as_bytes())?;
let dep_type_display = dep_type.replace('-', " ");
println!("{}", format!("Removed {dep_type_display} '{name}'").green());
let project_root = manifest_path.parent().unwrap();
let settings_path = project_root.join(".claude/settings.local.json");
if settings_path.exists() {
match resource_type {
ResourceType::McpServer => {
let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
if let Some(servers) = &mut settings.mcp_servers {
servers.remove(name);
}
settings.save(&settings_path)?;
}
ResourceType::Hook => {
let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
if let Some(hooks) = &mut settings.hooks
&& let Some(hooks_obj) = hooks.as_object_mut()
{
hooks_obj.remove(name);
}
settings.save(&settings_path)?;
}
_ => {}
}
}
let lockfile_path = manifest_path.parent().unwrap().join("agpm.lock");
if lockfile_path.exists() {
let mut lockfile = LockFile::load(&lockfile_path)?;
let installed_path = get_installed_path_from_lockfile(
&lockfile,
name,
resource_type,
project_root,
&manifest,
);
if let Some(path) = installed_path
&& path.exists()
{
tokio::fs::remove_file(&path)
.await
.with_context(|| format!("Failed to remove installed file: {}", path.display()))?;
}
remove_from_lockfile(&mut lockfile, name, resource_type);
lockfile.save(&lockfile_path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_remove_source_not_found() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
existing = "https://github.com/test/repo.git"
[agents]
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_source_with_manifest_path("nonexistent", false, Some(manifest_path.clone()))
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_remove_source_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
test-source = "https://github.com/test/repo.git"
another-source = "https://github.com/another/repo.git"
[agents]
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_source_with_manifest_path("test-source", false, Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.sources.contains_key("test-source"));
assert!(manifest.sources.contains_key("another-source"));
}
#[tokio::test]
async fn test_remove_source_in_use() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
used-source = "https://github.com/test/repo.git"
[agents]
test-agent = { source = "used-source", path = "agents/test.md", version = "v1.0.0" }
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_source_with_manifest_path("used-source", false, Some(manifest_path.clone()))
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("still being used"));
}
#[tokio::test]
async fn test_remove_source_in_use_with_force() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
used-source = "https://github.com/test/repo.git"
[agents]
test-agent = { source = "used-source", path = "agents/test.md", version = "v1.0.0" }
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_source_with_manifest_path("used-source", true, Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(!content.contains("used-source = \"https://github.com/test/repo.git\""));
}
#[tokio::test]
async fn test_remove_dependency_not_found() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"nonexistent",
"agent",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_remove_agent_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
test-agent = "../test/agent.md"
another-agent = "../test/another.md"
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-agent",
"agent",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.agents.contains_key("test-agent"));
assert!(manifest.agents.contains_key("another-agent"));
}
#[tokio::test]
async fn test_remove_snippet_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
test-snippet = "../test/snippet.md"
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-snippet",
"snippet",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.snippets.contains_key("test-snippet"));
}
#[tokio::test]
async fn test_remove_command_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
test-command = "../test/command.md"
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-command",
"command",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.commands.contains_key("test-command"));
}
#[tokio::test]
async fn test_remove_mcp_server_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
test-server = "../local/mcp-servers/test-server.json"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-server",
"mcp-server",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.mcp_servers.contains_key("test-server"));
}
#[tokio::test]
async fn test_remove_script_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
[scripts]
test-script = "../test/script.sh"
another-script = "../test/another.sh"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-script",
"script",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.scripts.contains_key("test-script"));
assert!(manifest.scripts.contains_key("another-script"));
}
#[tokio::test]
async fn test_remove_hook_success() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
[scripts]
[hooks]
pre-commit = "../test/hook.json"
post-commit = "../test/another_hook.json"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_dependency_with_manifest_path("pre-commit", "hook", Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let manifest = Manifest::load(&manifest_path).unwrap();
assert!(!manifest.hooks.contains_key("pre-commit"));
assert!(manifest.hooks.contains_key("post-commit"));
}
#[tokio::test]
async fn test_remove_invalid_dependency_type() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test",
"invalid-type",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid dependency type"));
}
#[tokio::test]
async fn test_remove_dependency_with_lockfile_suggestion() {
use crate::lockfile::{LockFile, LockedResource};
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest_content = r#"
[sources]
[agents]
test-agent = "../test/agent.md"
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let mut lockfile = LockFile::new();
lockfile.agents.push(LockedResource {
name: "test-agent".to_string(),
source: None,
url: None,
path: "../test/agent.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test".to_string(),
installed_at: "agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
});
lockfile.save(&lockfile_path).unwrap();
let result = remove_dependency_with_manifest_path(
"test-agent",
"agent",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.agents.len(), 0, "Agent should be removed from lockfile");
}
#[tokio::test]
async fn test_remove_source_checks_all_dependency_types() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
used-source = "https://github.com/test/repo.git"
[agents]
test-agent = { source = "used-source", path = "agents/test.md", version = "v1.0.0" }
[snippets]
test-snippet = { source = "used-source", path = "snippets/test.md", version = "v1.0.0" }
[commands]
test-command = { source = "used-source", path = "commands/test.md", version = "v1.0.0" }
[mcp-servers]
test-server = { source = "used-source", path = "servers/test.toml", version = "v1.0.0", command = "npx", args = ["test"] }
[scripts]
test-script = { source = "used-source", path = "scripts/test.sh", version = "v1.0.0" }
[hooks]
test-hook = { source = "used-source", path = "hooks/test.json", version = "v1.0.0" }
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let result =
remove_source_with_manifest_path("used-source", false, Some(manifest_path.clone()))
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("agent 'test-agent'"));
assert!(err_msg.contains("snippet 'test-snippet'"));
assert!(err_msg.contains("command 'test-command'"));
assert!(err_msg.contains("mcp-server 'test-server'"));
assert!(err_msg.contains("script 'test-script'"));
assert!(err_msg.contains("hook 'test-hook'"));
}
#[tokio::test]
async fn test_execute_remove_command() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest_content = r#"
[sources]
test = "https://github.com/test/repo.git"
[agents]
[snippets]
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let cmd = RemoveCommand {
command: RemoveSubcommand::Source {
name: "test".to_string(),
force: false,
},
};
let result = cmd.execute_with_manifest_path(Some(manifest_path.clone())).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_remove_deletes_installed_files() {
use crate::lockfile::{LockedResource, LockedSource};
let temp = TempDir::new().unwrap();
let project_dir = temp.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest = r#"
[sources]
test-source = "https://github.com/test/repo.git"
[agents]
test-agent = { source = "test-source", path = "agents/test.md", version = "v1.0.0" }
[snippets]
test-snippet = { source = "test-source", path = "snippets/test.md", version = "v1.0.0" }
"#;
fs::write(&manifest_path, manifest).unwrap();
let mut lockfile = LockFile {
version: 1,
..Default::default()
};
lockfile.sources.push(LockedSource {
name: "test-source".to_string(),
url: "https://github.com/test/repo.git".to_string(),
fetched_at: "2024-01-01T00:00:00Z".to_string(),
});
lockfile.agents.push(LockedResource {
name: "test-agent".to_string(),
source: Some("test-source".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
installed_at: ".claude/agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
});
lockfile.snippets.push(LockedResource {
name: "test-snippet".to_string(),
source: Some("test-source".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "snippets/test.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
installed_at: ".claude/snippets/test-snippet.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Snippet,
tool: "claude-code".to_string(),
});
lockfile.save(&lockfile_path).unwrap();
let agent_dir = project_dir.join(".claude/agents");
let snippet_dir = project_dir.join(".claude/snippets");
let agent_file = agent_dir.join("test-agent.md");
let snippet_file = snippet_dir.join("test-snippet.md");
std::fs::create_dir_all(&agent_dir).unwrap();
std::fs::create_dir_all(&snippet_dir).unwrap();
std::fs::write(&agent_file, "# Test Agent").unwrap();
std::fs::write(&snippet_file, "# Test Snippet").unwrap();
assert!(agent_file.exists(), "Agent file should exist before removal");
assert!(snippet_file.exists(), "Snippet file should exist before removal");
remove_dependency_with_manifest_path(
"test-snippet",
"snippet",
Some(manifest_path.clone()),
)
.await
.unwrap();
assert!(!snippet_file.exists(), "Snippet file should be deleted after removal");
assert!(agent_file.exists(), "Agent file should still exist after snippet removal");
remove_source_with_manifest_path("test-source", true, Some(manifest_path.clone()))
.await
.unwrap();
assert!(!agent_file.exists(), "Agent file should be deleted after source removal");
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.agents.len(), 0, "No agents should remain in lockfile");
assert_eq!(updated_lockfile.snippets.len(), 0, "No snippets should remain in lockfile");
assert_eq!(updated_lockfile.sources.len(), 0, "No sources should remain in lockfile");
}
#[tokio::test]
async fn test_remove_script_and_hook_from_lockfile() {
use crate::lockfile::{LockFile, LockedResource};
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
[scripts]
test-script = "../test/script.sh"
[hooks]
test-hook = "../test/hook.json"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let mut lockfile = LockFile::new();
lockfile.scripts.push(LockedResource {
name: "test-script".to_string(),
source: None,
url: None,
path: "../test/script.sh".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test".to_string(),
installed_at: ".claude/agpm/scripts/test-script.sh".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Script,
tool: "claude-code".to_string(),
});
lockfile.hooks.push(LockedResource {
name: "test-hook".to_string(),
source: None,
url: None,
path: "../test/hook.json".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test".to_string(),
installed_at: ".claude/agpm/hooks/test-hook.json".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Hook,
tool: "claude-code".to_string(),
});
lockfile.save(&lockfile_path).unwrap();
let result = remove_dependency_with_manifest_path(
"test-script",
"script",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.scripts.len(), 0);
assert_eq!(updated_lockfile.hooks.len(), 1);
let result =
remove_dependency_with_manifest_path("test-hook", "hook", Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let final_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(final_lockfile.hooks.len(), 0);
}
#[tokio::test]
async fn test_remove_updates_lockfile() {
use crate::lockfile::{LockFile, LockedResource, LockedSource};
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest_content = r#"
[sources]
test-source = "https://github.com/test/repo.git"
[agents]
test-agent = { source = "test-source", path = "agents/test.md", version = "v1.0.0" }
[snippets]
test-snippet = "../local/snippet.md"
[commands]
[mcp-servers]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let mut lockfile = LockFile::new();
lockfile.sources.push(LockedSource {
name: "test-source".to_string(),
url: "https://github.com/test/repo.git".to_string(),
fetched_at: chrono::Utc::now().to_rfc3339(),
});
lockfile.agents.push(LockedResource {
name: "test-agent".to_string(),
source: Some("test-source".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
installed_at: "agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
});
lockfile.snippets.push(LockedResource {
name: "test-snippet".to_string(),
source: None,
url: None,
path: "../local/snippet.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test".to_string(),
installed_at: "snippets/test-snippet.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Snippet,
tool: "claude-code".to_string(),
});
lockfile.save(&lockfile_path).unwrap();
let result = remove_dependency_with_manifest_path(
"test-snippet",
"snippet",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.snippets.len(), 0, "Snippet should be removed from lockfile");
assert_eq!(updated_lockfile.agents.len(), 1, "Agent should still be in lockfile");
let result = remove_dependency_with_manifest_path(
"test-agent",
"agent",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.agents.len(), 0, "Agent should be removed from lockfile");
assert_eq!(updated_lockfile.sources.len(), 1, "Source should still be in lockfile");
let result =
remove_source_with_manifest_path("test-source", false, Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.sources.len(), 0, "Source should be removed from lockfile");
}
#[tokio::test]
async fn test_remove_mcp_server_updates_settings() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let settings_dir = temp.path().join(".claude");
let settings_path = settings_dir.join("settings.local.json");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
test-server = "../mcp/test-server.json"
[scripts]
[hooks]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
std::fs::create_dir_all(&settings_dir).unwrap();
let settings_content = r#"
{
"mcpServers": {
"test-server": {
"command": "node",
"args": ["test.js"]
},
"other-server": {
"command": "python",
"args": ["other.py"]
}
}
}
"#;
fs::write(&settings_path, settings_content).unwrap();
let result = remove_dependency_with_manifest_path(
"test-server",
"mcp-server",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
let updated_settings = fs::read_to_string(&settings_path).unwrap();
assert!(!updated_settings.contains("test-server"));
assert!(updated_settings.contains("other-server"));
}
#[tokio::test]
async fn test_remove_hook_updates_settings() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let settings_dir = temp.path().join(".claude");
let settings_path = settings_dir.join("settings.local.json");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
[scripts]
[hooks]
test-hook = "../hooks/test-hook.json"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
std::fs::create_dir_all(&settings_dir).unwrap();
let settings_content = r#"
{
"hooks": {
"test-hook": {
"command": "echo test"
},
"other-hook": {
"command": "echo other"
}
}
}
"#;
fs::write(&settings_path, settings_content).unwrap();
let result =
remove_dependency_with_manifest_path("test-hook", "hook", Some(manifest_path.clone()))
.await;
assert!(result.is_ok());
let updated_settings = fs::read_to_string(&settings_path).unwrap();
assert!(!updated_settings.contains("test-hook"));
assert!(updated_settings.contains("other-hook"));
}
#[tokio::test]
async fn test_remove_script_with_lockfile_entry() {
use crate::lockfile::{LockFile, LockedResource};
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let script_dir = temp.path().join(".claude/agpm/scripts");
let script_file = script_dir.join("test-script.sh");
let manifest_content = r#"
[sources]
[agents]
[snippets]
[commands]
[mcp-servers]
[scripts]
test-script = "../test/script.sh"
[hooks]
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let mut lockfile = LockFile::new();
lockfile.scripts.push(LockedResource {
name: "test-script".to_string(),
source: None,
url: None,
path: "../test/script.sh".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test".to_string(),
installed_at: ".claude/agpm/scripts/test-script.sh".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Script,
tool: "claude-code".to_string(),
});
lockfile.save(&lockfile_path).unwrap();
std::fs::create_dir_all(&script_dir).unwrap();
fs::write(&script_file, "#!/bin/bash\necho test").unwrap();
assert!(script_file.exists());
let result = remove_dependency_with_manifest_path(
"test-script",
"script",
Some(manifest_path.clone()),
)
.await;
assert!(result.is_ok());
assert!(!script_file.exists());
let updated_lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(updated_lockfile.scripts.len(), 0);
}
}