use anyhow::Result;
use crate::common::{ManifestBuilder, TestProject};
#[tokio::test]
async fn test_transitive_resolution_basic() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let community_repo = project.create_source_repo("community").await?;
community_repo
.add_resource(
"agents",
"helper",
r#"---
# Helper Agent
This is a helper agent with no dependencies.
---
"#,
)
.await?;
community_repo
.add_resource(
"agents",
"main-app",
r#"---
dependencies:
agents:
- path: ./helper.md
version: v1.0.0
---
# Main App Agent
This agent depends on the helper agent.
"#,
)
.await?;
community_repo.commit_all("Initial commit")?;
community_repo.tag_version("v1.0.0")?;
let source_url = community_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("main-app", "community", "agents/main-app.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed. Stderr: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains("main-app"), "Main agent should be in lockfile");
assert!(
lockfile_content.contains("helper"),
"Helper agent should be in lockfile (transitive). Lockfile:\n{}\nStderr: {}",
lockfile_content,
output.stderr
);
let main_app_path = project.project_path().join(".claude/agents/agpm/main-app.md");
let helper_path = project.project_path().join(".claude/agents/agpm/helper.md");
assert!(
tokio::fs::metadata(&main_app_path).await.is_ok(),
"Main agent file should exist at {:?}",
main_app_path
);
assert!(
tokio::fs::metadata(&helper_path).await.is_ok(),
"Helper agent file should exist at {:?}",
helper_path
);
Ok(())
}
#[tokio::test]
async fn test_transitive_cross_source_same_names() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let source1_repo = project.create_source_repo("source1").await?;
source1_repo
.add_resource("agents", "utils", "# Utils from Source 1\n\nSource 1 utilities")
.await?;
source1_repo
.add_resource(
"agents",
"app",
r#"---
dependencies:
agents:
- path: ./utils.md
version: v1.0.0
---
# App from Source 1
Uses utils from same source
"#,
)
.await?;
source1_repo.commit_all("Source 1 commit")?;
source1_repo.tag_version("v1.0.0")?;
let source2_repo = project.create_source_repo("source2").await?;
source2_repo
.add_resource("agents", "utils", "# Utils from Source 2\n\nSource 2 utilities (different)")
.await?;
source2_repo
.add_resource(
"agents",
"tool",
r#"---
dependencies:
agents:
- path: ./utils.md
version: v1.0.0
---
# Tool from Source 2
Uses utils from same source
"#,
)
.await?;
source2_repo.commit_all("Source 2 commit")?;
source2_repo.tag_version("v1.0.0")?;
let source1_url = source1_repo.bare_file_url(project.sources_path()).await?;
let source2_url = source2_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_sources(&[("source1", &source1_url), ("source2", &source2_url)])
.add_standard_agent("app", "source1", "agents/app.md")
.add_standard_agent("tool", "source2", "agents/tool.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Install should fail due to path conflict for cross-source same-named transitive deps"
);
assert!(
output.stderr.contains("Target path conflicts"),
"Should report path conflict, got: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_transitive_cycle_detection() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
repo.add_resource(
"agents",
"agent-a",
r#"---
dependencies:
agents:
- path: ./agent-b.md
version: v1.0.0
---
# Agent A
Depends on Agent B
"#,
)
.await?;
repo.add_resource(
"agents",
"agent-b",
r#"---
dependencies:
agents:
- path: ./agent-c.md
version: v1.0.0
---
# Agent B
Depends on Agent C
"#,
)
.await?;
repo.add_resource(
"agents",
"agent-c",
r#"---
dependencies:
agents:
- path: ./agent-a.md
version: v1.0.0
---
# Agent C
Depends on Agent A (creates cycle)
"#,
)
.await?;
repo.commit_all("Add agents with circular dependencies")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("agent-a", "community", "agents/agent-a.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(!output.success, "Install should fail due to circular dependency");
assert!(
output.stderr.contains("Circular dependency") || output.stderr.contains("cycle"),
"Error should mention circular dependency or cycle, got: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_transitive_diamond_dependencies() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
repo.add_resource(
"agents",
"agent-d",
r#"---
# Agent D
Base agent with no dependencies
---
"#,
)
.await?;
repo.add_resource(
"agents",
"agent-b",
r#"---
dependencies:
agents:
- path: ./agent-d.md
version: v1.0.0
---
# Agent B
Depends on Agent D
"#,
)
.await?;
repo.add_resource(
"agents",
"agent-c",
r#"---
dependencies:
agents:
- path: ./agent-d.md
version: v1.0.0
---
# Agent C
Depends on Agent D
"#,
)
.await?;
repo.add_resource(
"agents",
"agent-a",
r#"---
dependencies:
agents:
- path: ./agent-b.md
version: v1.0.0
- path: ./agent-c.md
version: v1.0.0
---
# Agent A
Depends on both Agent B and Agent C
"#,
)
.await?;
repo.commit_all("Add agents with diamond dependency pattern")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("agent-a", "community", "agents/agent-a.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed with diamond dependencies: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains("agent-a"), "Agent A should be in lockfile");
assert!(lockfile_content.contains("agent-b"), "Agent B should be in lockfile");
assert!(lockfile_content.contains("agent-c"), "Agent C should be in lockfile");
assert!(lockfile_content.contains("agent-d"), "Agent D should be in lockfile");
let agent_d_count = lockfile_content.matches("name = \"agents/agent-d\"").count();
assert_eq!(
agent_d_count, 1,
"Agent D should appear exactly once in lockfile (deduplication), found {}",
agent_d_count
);
Ok(())
}
#[tokio::test]
async fn test_transitive_deps_duplicate_names_different_paths() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
let commands_dir = repo.path.join("snippets/commands");
tokio::fs::create_dir_all(&commands_dir).await?;
tokio::fs::write(
commands_dir.join("commit.md"),
"# Commands Commit\n\nThis is the commands version of commit.",
)
.await?;
let logit_dir = repo.path.join("snippets/logit");
tokio::fs::create_dir_all(&logit_dir).await?;
tokio::fs::write(
logit_dir.join("commit.md"),
"# Logit Commit\n\nThis is the logit version of commit.",
)
.await?;
repo.add_resource(
"commands",
"commit-cmd",
r#"---
dependencies:
snippets:
- path: ../snippets/commands/commit.md
version: v1.0.0
- path: ../snippets/logit/commit.md
version: v1.0.0
---
# Commit Command
This command depends on both commit snippets.
"#,
)
.await?;
repo.commit_all("Add resources with duplicate names")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_command("commit-cmd", |d| {
d.source("community").path("commands/commit-cmd.md").version("v1.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed, stderr: {}", output.stderr);
let commands_snippet_path =
project.project_path().join(".claude/snippets/agpm/commands/commit.md");
let logit_snippet_path = project.project_path().join(".claude/snippets/agpm/logit/commit.md");
assert!(
tokio::fs::metadata(&commands_snippet_path).await.is_ok(),
"Commands commit snippet should exist at {:?}",
commands_snippet_path
);
assert!(
tokio::fs::metadata(&logit_snippet_path).await.is_ok(),
"Logit commit snippet should exist at {:?}",
logit_snippet_path
);
let commands_content = tokio::fs::read_to_string(&commands_snippet_path).await?;
assert!(
commands_content.contains("commands version"),
"Commands snippet should contain 'commands version', got: {}",
commands_content
);
let logit_content = tokio::fs::read_to_string(&logit_snippet_path).await?;
assert!(
logit_content.contains("logit version"),
"Logit snippet should contain 'logit version', got: {}",
logit_content
);
Ok(())
}
#[tokio::test]
async fn test_transitive_deps_cross_type_collision() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
repo.add_resource("agents", "helper", "# Helper Agent\n\nThis is the helper agent.").await?;
repo.add_resource("commands", "helper", "# Helper Command\n\nThis is the helper command.")
.await?;
repo.add_resource(
"agents",
"main",
r#"---
dependencies:
agents:
- path: ./helper.md
version: v1.0.0
commands:
- path: ../commands/helper.md
version: v1.0.0
---
# Main Agent
This agent depends on both helper agent and command with the same name.
"#,
)
.await?;
repo.commit_all("Add resources with cross-type name collision")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("main", "community", "agents/main.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed, stderr: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
let has_agent_helper = lockfile_content.contains("[[agents]]")
&& lockfile_content.contains(r#"name = "agents/helper""#)
&& lockfile_content.contains(r#"path = "agents/helper.md""#);
let has_command_helper = lockfile_content.contains("[[commands]]")
&& lockfile_content.contains(r#"name = "commands/helper""#)
&& lockfile_content.contains(r#"path = "commands/helper.md""#);
assert!(
has_agent_helper,
"Lockfile should have helper agent in [[agents]] section:\n{}",
lockfile_content
);
assert!(
has_command_helper,
"Lockfile should have helper command in [[commands]] section:\n{}",
lockfile_content
);
let agent_path = project.project_path().join(".claude/agents/agpm/helper.md");
let command_path = project.project_path().join(".claude/commands/agpm/helper.md");
assert!(
tokio::fs::metadata(&agent_path).await.is_ok(),
"Helper agent should exist at {:?}",
agent_path
);
assert!(
tokio::fs::metadata(&command_path).await.is_ok(),
"Helper command should exist at {:?}",
command_path
);
Ok(())
}
#[tokio::test]
async fn test_install_update_parity() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
repo.add_resource("snippets", "helper", "# Helper Snippet\n\nA helper snippet.").await?;
repo.add_resource(
"commands",
"deploy",
r#"---
dependencies:
snippets:
- path: ../snippets/helper.md
version: v1.0.0
---
# Deploy Command
This command depends on the helper snippet.
"#,
)
.await?;
repo.commit_all("Initial release")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_command("deploy", |d| {
d.source("community").path("commands/deploy.md").version("v1.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed: {}", output.stderr);
let install_lockfile = project.read_lockfile().await?;
let claude_dir = project.project_path().join(".claude");
if tokio::fs::metadata(&claude_dir).await.is_ok() {
tokio::fs::remove_dir_all(&claude_dir).await?;
}
tokio::fs::remove_file(project.project_path().join("agpm.lock")).await?;
let output = project.run_agpm(&["update"])?;
assert!(output.success, "Update should succeed: {}", output.stderr);
let update_lockfile = project.read_lockfile().await?;
let install_agents_count = install_lockfile.matches("[[agents]]").count();
let update_agents_count = update_lockfile.matches("[[agents]]").count();
let install_commands_count = install_lockfile.matches("[[commands]]").count();
let update_commands_count = update_lockfile.matches("[[commands]]").count();
let install_snippets_count = install_lockfile.matches("[[snippets]]").count();
let update_snippets_count = update_lockfile.matches("[[snippets]]").count();
assert_eq!(
install_agents_count, update_agents_count,
"Install and update should have same number of agents.\nInstall lockfile:\n{}\n\nUpdate lockfile:\n{}",
install_lockfile, update_lockfile
);
assert_eq!(
install_commands_count, update_commands_count,
"Install and update should have same number of commands.\nInstall lockfile:\n{}\n\nUpdate lockfile:\n{}",
install_lockfile, update_lockfile
);
assert_eq!(
install_snippets_count, update_snippets_count,
"Install and update should have same number of snippets.\nInstall lockfile:\n{}\n\nUpdate lockfile:\n{}",
install_lockfile, update_lockfile
);
let deploy_in_install = install_lockfile.contains(r#"name = "commands/deploy""#);
let deploy_in_update = update_lockfile.contains(r#"name = "commands/deploy""#);
assert!(deploy_in_install && deploy_in_update, "Deploy command should exist in both lockfiles");
let helper_in_install = install_lockfile.contains(r#"name = "snippets/helper""#);
let helper_in_update = update_lockfile.contains(r#"name = "snippets/helper""#);
assert!(helper_in_install && helper_in_update, "Helper snippet should exist in both lockfiles");
Ok(())
}
#[tokio::test]
async fn test_shared_dependency_deduplication() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let repo = project.create_source_repo("community").await?;
repo.add_resource("snippets", "shared", "# Shared Snippet\n\nUsed by multiple resources.")
.await?;
repo.add_resource(
"agents",
"parent",
r#"---
dependencies:
snippets:
- path: ../snippets/shared.md
version: v1.0.0
---
# Parent Agent
Depends on shared snippet.
"#,
)
.await?;
repo.commit_all("Add resources")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("parent", "community", "agents/parent.md")
.add_standard_snippet("shared", "community", "snippets/shared.md")
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let shared_agpm_path = project.project_path().join(".agpm/snippets/shared.md");
assert!(
tokio::fs::metadata(&shared_agpm_path).await.is_ok(),
"Shared snippet (agpm) should be installed"
);
let shared_claude_path = project.project_path().join(".claude/snippets/agpm/shared.md");
assert!(
tokio::fs::metadata(&shared_claude_path).await.is_ok(),
"Shared snippet (claude-code) should be installed"
);
let lockfile_content = project.read_lockfile().await?;
let shared_count = lockfile_content.matches(r#"name = "snippets/shared""#).count();
assert_eq!(
shared_count, 2,
"Should have two lockfile entries for shared (one per tool), found {}",
shared_count
);
let parent_path = project.project_path().join(".claude/agents/agpm/parent.md");
assert!(tokio::fs::metadata(&parent_path).await.is_ok(), "Parent agent should be installed");
assert!(
lockfile_content.contains(r#"name = "snippets/shared""#)
&& lockfile_content.contains("v1.0.0"),
"Lockfile should show shared at v1.0.0"
);
Ok(())
}