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())?;
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/main-app.md");
let helper_path = project.project_path().join(".claude/agents/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())?;
let source2_url = source2_repo.bare_file_url(project.sources_path())?;
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())?;
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())?;
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 = \"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())?;
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/commands/commit.md");
let logit_snippet_path = project.project_path().join(".claude/snippets/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())?;
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 = "helper""#)
&& lockfile_content.contains(r#"path = "agents/helper.md""#);
let has_command_helper = lockfile_content.contains("[[commands]]")
&& lockfile_content.contains(r#"name = "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/helper.md");
let command_path = project.project_path().join(".claude/commands/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_version_conflict_uses_correct_metadata() -> 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("commands", "old-command", "# Old Command\n\nThis is the old command.")
.await?;
repo.add_resource(
"snippets",
"shared",
r#"---
dependencies:
commands:
- path: ../commands/old-command.md
version: v1.0.0
---
# Shared Snippet v1.0.0
This is version 1.0.0 of the shared snippet.
"#,
)
.await?;
repo.commit_all("Release v1.0.0")?;
repo.tag_version("v1.0.0")?;
tokio::fs::remove_file(repo.path.join("commands/old-command.md")).await?;
repo.add_resource("commands", "new-command", "# New Command\n\nThis is the new command.")
.await?;
repo.add_resource(
"snippets",
"shared",
r#"---
dependencies:
commands:
- path: ../commands/new-command.md
version: v2.0.0
---
# Shared Snippet v2.0.0
This is version 2.0.0 of the shared snippet.
"#,
)
.await?;
repo.commit_all("Release v2.0.0")?;
repo.tag_version("v2.0.0")?;
repo.add_resource(
"agents",
"first",
r#"---
dependencies:
snippets:
- path: ../snippets/shared.md
version: v2.0.0
---
# First Agent
Requires shared@v2.0.0
"#,
)
.await?;
repo.add_resource(
"agents",
"second",
r#"---
dependencies:
snippets:
- path: ../snippets/shared.md
version: v2.0.0
---
# Second Agent
Also requires shared@v2.0.0
"#,
)
.await?;
repo.commit_all("Add agents")?;
repo.tag_version("v2.0.1")?;
let source_url = repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_agent("first", |d| d.source("community").path("agents/first.md").version("v2.0.1"))
.add_agent("second", |d| d.source("community").path("agents/second.md").version("v2.0.1"))
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed, stderr: {}", output.stderr);
let new_command_path = project.project_path().join(".claude/commands/new-command.md");
let old_command_path = project.project_path().join(".claude/commands/old-command.md");
assert!(
tokio::fs::metadata(&new_command_path).await.is_ok(),
"New command should exist at {:?} (from v2.0.0 metadata)",
new_command_path
);
assert!(
tokio::fs::metadata(&old_command_path).await.is_err(),
"Old command should NOT exist at {:?} (v1.0.0 metadata should not be used)",
old_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())?;
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 = "deploy""#);
let deploy_in_update = update_lockfile.contains(r#"name = "deploy""#);
assert!(deploy_in_install && deploy_in_update, "Deploy command should exist in both lockfiles");
let helper_in_install = install_lockfile.contains(r#"name = "helper""#);
let helper_in_update = update_lockfile.contains(r#"name = "helper""#);
assert!(helper_in_install && helper_in_update, "Helper snippet should exist in both lockfiles");
Ok(())
}
#[test]
fn test_generate_dependency_name_collisions() {
use std::path::Path;
fn generate_dependency_name_current(path: &str) -> String {
let path = Path::new(path);
let without_ext = path.with_extension("");
let path_str = without_ext.to_string_lossy();
let components: Vec<&str> = path_str.split('/').collect();
if components.len() > 1 {
components[1..].join("/")
} else {
components[0].to_string()
}
}
let name1 = generate_dependency_name_current("snippets/commands/commit.md");
let name2 = generate_dependency_name_current("snippets/logit/commit.md");
let name3 = generate_dependency_name_current("snippets/utils/commit.md");
println!("Corrected name generation:");
println!(" snippets/commands/commit.md -> {}", name1);
println!(" snippets/logit/commit.md -> {}", name2);
println!(" snippets/utils/commit.md -> {}", name3);
let name4 = generate_dependency_name_current("snippets/commands/commit.md");
assert_eq!(name1, name4, "Same path should generate same name");
assert_eq!(name1, "commands/commit");
assert_eq!(name2, "logit/commit");
assert_eq!(name3, "utils/commit");
}
#[tokio::test]
async fn test_type_resolution_fallback_ambiguity() -> 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\nHelper snippet.").await?;
repo.add_resource("agents", "helper", "# Helper Agent\n\nHelper agent.").await?;
repo.add_resource(
"agents",
"main",
r#"---
dependencies:
snippets:
- path: ../snippets/helper.md
version: v1.0.0
agents:
- path: ./helper.md
version: v1.0.0
---
# Main Agent
This agent depends on both helper snippet and helper agent (same name, different types).
"#,
)
.await?;
repo.commit_all("Add resources")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path())?;
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: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
let has_snippet_helper = lockfile_content.contains(r#"name = "helper""#)
&& lockfile_content.contains(r#"path = "snippets/helper.md""#);
assert!(has_snippet_helper, "Lockfile should have helper snippet:\n{}", lockfile_content);
let has_agent_helper = lockfile_content.contains(r#"name = "helper""#)
&& lockfile_content.contains(r#"path = "agents/helper.md""#);
assert!(has_agent_helper, "Lockfile should have helper agent:\n{}", lockfile_content);
let snippet_path = project.project_path().join(".claude/snippets/helper.md");
let agent_path = project.project_path().join(".claude/agents/helper.md");
assert!(
tokio::fs::metadata(&snippet_path).await.is_ok(),
"Snippet helper should be installed at {:?}",
snippet_path
);
assert!(
tokio::fs::metadata(&agent_path).await.is_ok(),
"Agent helper should be installed at {:?}",
agent_path
);
Ok(())
}
#[tokio::test]
async fn test_transitive_version_conflict_metadata_from_winner() -> 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("commands", "old-dep", "# Old Dep\n\nOld command.").await?;
repo.add_resource(
"snippets",
"shared",
r#"---
dependencies:
commands:
- path: ../commands/old-dep.md
---
# Shared v1.0.0
Version 1 with old-dep.
"#,
)
.await?;
repo.commit_all("Add v1.0.0 resources")?;
repo.tag_version("v1.0.0")?;
repo.add_resource("commands", "new-dep", "# New Dep\n\nNew command.").await?;
repo.add_resource(
"snippets",
"shared",
r#"---
dependencies:
commands:
- path: ../commands/new-dep.md
---
# Shared v2.0.0
Version 2 with new-dep.
"#,
)
.await?;
repo.commit_all("Update to v2.0.0")?;
repo.tag_version("v2.0.0")?;
repo.add_resource(
"agents",
"parent-a",
r#"---
dependencies:
snippets:
- path: ../snippets/shared.md
version: ">=v1.0.0"
---
# Parent A
Depends on shared@>=v1.0.0 (accepts any version >= 1.0.0).
"#,
)
.await?;
repo.add_resource(
"agents",
"parent-b",
r#"---
dependencies:
snippets:
- path: ../snippets/shared.md
version: ">=v1.5.0"
---
# Parent B
Depends on shared@>=v1.5.0 (intersection with parent-a is >=v1.5.0).
"#,
)
.await?;
repo.commit_all("Add parent agents")?;
repo.tag_version("v3.0.0")?;
let source_url = repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_agent("parent-a", |d| {
d.source("community").path("agents/parent-a.md").version("v3.0.0")
})
.add_agent("parent-b", |d| {
d.source("community").path("agents/parent-b.md").version("v3.0.0")
})
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let new_dep_path = project.project_path().join(".claude/commands/new-dep.md");
let old_dep_path = project.project_path().join(".claude/commands/old-dep.md");
assert!(
tokio::fs::metadata(&new_dep_path).await.is_ok(),
"new-dep should be installed (exists at v3.0.0)"
);
assert!(
tokio::fs::metadata(&old_dep_path).await.is_err(),
"old-dep should NOT be installed (doesn't exist at v3.0.0)"
);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains(r#"name = "shared""#) && lockfile_content.contains("v3.0.0"),
"Lockfile should show shared at v3.0.0 (highest version satisfying both constraints)"
);
let shared_path = project.project_path().join(".claude/snippets/shared.md");
let shared_content = tokio::fs::read_to_string(&shared_path).await?;
assert!(
shared_content.contains("Version 2 with new-dep"),
"Shared snippet should have v2.0.0 content (unchanged in v3.0.0)"
);
Ok(())
}
#[tokio::test]
async fn test_cross_source_same_name_disambiguation() -> 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("snippets", "helper", "# Community Helper\n\nFrom community source.")
.await?;
community_repo
.add_resource(
"agents",
"main",
r#"---
dependencies:
snippets:
- path: ../snippets/helper.md
version: v1.0.0
---
# Main Agent
Depends on community helper.
"#,
)
.await?;
community_repo.commit_all("Add resources")?;
community_repo.tag_version("v1.0.0")?;
let local_repo = project.create_source_repo("local").await?;
local_repo.add_resource("snippets", "helper", "# Local Helper\n\nFrom local source.").await?;
local_repo.commit_all("Add local helper")?;
local_repo.tag_version("v1.0.0")?;
let community_url = community_repo.bare_file_url(project.sources_path())?;
let local_url = local_repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &community_url)
.add_source("local", &local_url)
.add_standard_agent("main", "community", "agents/main.md")
.add_standard_snippet("local-helper", "local", "snippets/helper.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
output.success,
"Install should succeed - snippets use different tools and paths. Stderr: {}",
output.stderr
);
let community_helper = project.project_path().join(".claude/snippets/helper.md");
let local_helper = project.project_path().join(".agpm/snippets/helper.md");
assert!(
tokio::fs::metadata(&community_helper).await.is_ok(),
"Community helper should be installed to .claude/snippets (inherited claude-code from agent)"
);
assert!(
tokio::fs::metadata(&local_helper).await.is_ok(),
"Local helper should be installed to .agpm/snippets (using snippet's default tool)"
);
let community_content = tokio::fs::read_to_string(&community_helper).await?;
let local_content = tokio::fs::read_to_string(&local_helper).await?;
assert!(
community_content.contains("Community Helper"),
"Community helper should have correct content"
);
assert!(local_content.contains("Local Helper"), "Local helper should have correct content");
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())?;
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/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 = "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/parent.md");
assert!(tokio::fs::metadata(&parent_path).await.is_ok(), "Parent agent should be installed");
assert!(
lockfile_content.contains(r#"name = "shared""#) && lockfile_content.contains("v1.0.0"),
"Lockfile should show shared at v1.0.0"
);
Ok(())
}
#[tokio::test]
async fn test_local_file_dependency_skips_transitive_with_warning() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let local_agent_path = project.project_path().join("local-agent.md");
let local_agent_content = r#"---
dependencies:
snippets:
- path: ../snippets/helper.md
version: v1.0.0
---
# Local Agent
This is a local agent with transitive dependencies.
"#;
tokio::fs::write(&local_agent_path, local_agent_content).await?;
let manifest = ManifestBuilder::new().add_local_agent("local-agent", "local-agent.md").build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Install should fail when transitive dependency path resolution fails"
);
assert!(
output.stderr.contains("Failed to resolve transitive dependency")
|| output.stderr.contains("Failed to fetch resource")
|| output.stderr.contains("file access"),
"Error should indicate transitive dependency failure, got: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_transitive_pattern_dependency_expands() -> 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("commands", "cmd-one", "# Command One\n\nFirst command.").await?;
repo.add_resource("commands", "cmd-two", "# Command Two\n\nSecond command.").await?;
repo.add_resource(
"snippets",
"helper-one",
r#"---
dependencies:
commands:
- path: ../commands/cmd-one.md
---
# Helper One
First helper with transitive dependency on cmd-one.
"#,
)
.await?;
repo.add_resource(
"snippets",
"helper-two",
r#"---
dependencies:
commands:
- path: ../commands/cmd-two.md
---
# Helper Two
Second helper with transitive dependency on cmd-two.
"#,
)
.await?;
repo.add_resource(
"agents",
"parent",
r#"---
dependencies:
snippets:
- path: ../snippets/helper-*.md
---
# Parent Agent
Has a glob pattern in transitive dependencies that matches multiple snippets.
Each snippet has its own transitive dependencies.
"#,
)
.await?;
repo.commit_all("Add resources")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("parent", "community", "agents/parent.md")
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let parent_path = project.project_path().join(".claude/agents/parent.md");
assert!(tokio::fs::metadata(&parent_path).await.is_ok(), "Parent agent should be installed");
let helper_one_path = project.project_path().join(".claude/snippets/helper-one.md");
let helper_two_path = project.project_path().join(".claude/snippets/helper-two.md");
assert!(
tokio::fs::metadata(&helper_one_path).await.is_ok(),
"Helper-one should be installed (matched by pattern)"
);
assert!(
tokio::fs::metadata(&helper_two_path).await.is_ok(),
"Helper-two should be installed (matched by pattern)"
);
let cmd_one_path = project.project_path().join(".claude/commands/cmd-one.md");
let cmd_two_path = project.project_path().join(".claude/commands/cmd-two.md");
assert!(
tokio::fs::metadata(&cmd_one_path).await.is_ok(),
"cmd-one should be installed (transitive dep of helper-one)"
);
assert!(
tokio::fs::metadata(&cmd_two_path).await.is_ok(),
"cmd-two should be installed (transitive dep of helper-two)"
);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains(r#"name = "parent""#), "Lockfile should contain parent");
assert!(
lockfile_content.contains(r#"name = "helper-one""#),
"Lockfile should contain helper-one"
);
assert!(
lockfile_content.contains(r#"name = "helper-two""#),
"Lockfile should contain helper-two"
);
assert!(lockfile_content.contains(r#"name = "cmd-one""#), "Lockfile should contain cmd-one");
assert!(lockfile_content.contains(r#"name = "cmd-two""#), "Lockfile should contain cmd-two");
Ok(())
}
#[tokio::test]
async fn test_manifest_pattern_has_transitive_deps_resolved() -> 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("commands", "cmd-a", "# Command A\n\nFirst command.").await?;
repo.add_resource("commands", "cmd-b", "# Command B\n\nSecond command.").await?;
repo.add_resource(
"snippets",
"util-one",
r#"---
dependencies:
commands:
- path: ../commands/cmd-a.md
---
# Util One
First utility with transitive dependency on cmd-a.
"#,
)
.await?;
repo.add_resource(
"snippets",
"util-two",
r#"---
dependencies:
commands:
- path: ../commands/cmd-b.md
---
# Util Two
Second utility with transitive dependency on cmd-b.
"#,
)
.await?;
repo.commit_all("Add resources")?;
repo.tag_version("v1.0.0")?;
let source_url = repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_snippet("util-pattern", |d| {
d.source("community").path("snippets/util-*.md").version("v1.0.0")
})
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let util_one_path = project.project_path().join(".agpm/snippets/util-one.md");
let util_two_path = project.project_path().join(".agpm/snippets/util-two.md");
assert!(
tokio::fs::metadata(&util_one_path).await.is_ok(),
"util-one should be installed (matched by manifest pattern)"
);
assert!(
tokio::fs::metadata(&util_two_path).await.is_ok(),
"util-two should be installed (matched by manifest pattern)"
);
let cmd_a_path = project.project_path().join(".claude/commands/cmd-a.md");
let cmd_b_path = project.project_path().join(".claude/commands/cmd-b.md");
assert!(
tokio::fs::metadata(&cmd_a_path).await.is_ok(),
"cmd-a should be installed (transitive dep of util-one)"
);
assert!(
tokio::fs::metadata(&cmd_b_path).await.is_ok(),
"cmd-b should be installed (transitive dep of util-two)"
);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains(r#"name = "util-one""#), "Lockfile should contain util-one");
assert!(lockfile_content.contains(r#"name = "util-two""#), "Lockfile should contain util-two");
assert!(lockfile_content.contains(r#"name = "cmd-a""#), "Lockfile should contain cmd-a");
assert!(lockfile_content.contains(r#"name = "cmd-b""#), "Lockfile should contain cmd-b");
Ok(())
}
#[tokio::test]
async fn test_mixed_local_remote_transitive_tree() -> 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", "remote-helper", "# Remote Helper\n\nFrom Git source.").await?;
repo.add_resource(
"agents",
"remote-parent",
r#"---
dependencies:
snippets:
- path: ../snippets/remote-helper.md
version: v1.0.0
---
# Remote Parent Agent
Depends on remote-helper from same Git source.
"#,
)
.await?;
repo.commit_all("Add remote resources")?;
repo.tag_version("v1.0.0")?;
let local_snippet_path = project.project_path().join("local-snippet.md");
let local_snippet_content = "# Local Snippet\n\nLocal file without transitive dependencies.";
tokio::fs::write(&local_snippet_path, local_snippet_content).await?;
let source_url = repo.bare_file_url(project.sources_path())?;
let manifest = ManifestBuilder::new()
.add_source("community", &source_url)
.add_standard_agent("remote-parent", "community", "agents/remote-parent.md")
.add_local_snippet("local-snippet", "local-snippet.md")
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let installed_local = project.project_path().join(".agpm/snippets/local-snippet.md");
assert!(
tokio::fs::metadata(&installed_local).await.is_ok(),
"Local snippet should be installed"
);
let installed_remote_parent = project.project_path().join(".claude/agents/remote-parent.md");
assert!(
tokio::fs::metadata(&installed_remote_parent).await.is_ok(),
"Remote parent agent should be installed"
);
let installed_remote_helper = project.project_path().join(".claude/snippets/remote-helper.md");
assert!(
tokio::fs::metadata(&installed_remote_helper).await.is_ok(),
"Remote helper (transitive) should be installed"
);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains(r#"name = "local-snippet""#),
"Lockfile should contain local-snippet"
);
assert!(
lockfile_content.contains(r#"name = "remote-parent""#),
"Lockfile should contain remote-parent"
);
assert!(
lockfile_content.contains(r#"name = "remote-helper""#),
"Lockfile should contain remote-helper (transitive)"
);
assert!(
lockfile_content.contains(r#"source = "community""#),
"Lockfile should show community source for remote resources"
);
Ok(())
}
#[tokio::test]
async fn test_local_with_current_dir_transitive() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let agents_dir = project.project_path().join("agents");
tokio::fs::create_dir_all(&agents_dir).await?;
let helper_path = agents_dir.join("helper.md");
tokio::fs::write(&helper_path, "# Helper Agent\n\nA helper agent without dependencies.")
.await?;
let local_agent_path = agents_dir.join("local-agent.md");
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
agents:
- path: ./helper.md
---
# Local Agent
This is a local agent with a transitive dependency on ./helper.md.
"#,
)
.await?;
let manifest =
ManifestBuilder::new().add_local_agent("local-agent", "agents/local-agent.md").build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let installed_local = project.project_path().join(".claude/agents/local-agent.md");
let installed_helper = project.project_path().join(".claude/agents/helper.md");
assert!(
tokio::fs::metadata(&installed_local).await.is_ok(),
"Local agent should be installed at {:?}",
installed_local
);
assert!(
tokio::fs::metadata(&installed_helper).await.is_ok(),
"Helper agent (transitive) should be installed at {:?}",
installed_helper
);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains(r#"name = "local-agent""#),
"Lockfile should contain local-agent"
);
assert!(
lockfile_content.contains(r#"name = "helper""#),
"Lockfile should contain helper (transitive). Lockfile:\n{}",
lockfile_content
);
assert!(lockfile_content.contains(r#"path = "agents/helper.md""#));
Ok(())
}
#[tokio::test]
async fn test_local_with_parent_dir_transitive() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let agents_dir = project.project_path().join("agents");
let subfolder = agents_dir.join("subfolder");
tokio::fs::create_dir_all(&subfolder).await?;
let helper_path = agents_dir.join("helper.md");
tokio::fs::write(&helper_path, "# Helper Agent\n\nA helper agent without dependencies.")
.await?;
let local_agent_path = subfolder.join("local-agent.md");
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
agents:
- path: ../helper.md
---
# Local Agent
This agent depends on ../helper.md (parent directory).
"#,
)
.await?;
let manifest = ManifestBuilder::new()
.add_agent("local-agent", |d| d.path("agents/subfolder/local-agent.md").flatten(false))
.build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let installed_local = project.project_path().join(".claude/agents/subfolder/local-agent.md");
let installed_helper = project.project_path().join(".claude/agents/helper.md");
assert!(tokio::fs::metadata(&installed_local).await.is_ok(), "Local agent should be installed");
assert!(
tokio::fs::metadata(&installed_helper).await.is_ok(),
"Helper agent (transitive from parent dir) should be installed"
);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains(r#"name = "local-agent""#));
assert!(lockfile_content.contains(r#"name = "helper""#));
assert!(lockfile_content.contains(r#"path = "agents/helper.md""#));
Ok(())
}
#[tokio::test]
async fn test_local_with_cross_directory_transitive() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let agents_dir = project.project_path().join("agents");
let snippets_dir = project.project_path().join("snippets");
tokio::fs::create_dir_all(&agents_dir).await?;
tokio::fs::create_dir_all(&snippets_dir).await?;
let utils_path = snippets_dir.join("utils.md");
tokio::fs::write(&utils_path, "# Utils Snippet\n\nUtility functions.").await?;
let local_agent_path = agents_dir.join("local-agent.md");
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
snippets:
- path: ../snippets/utils.md
---
# Local Agent
This agent depends on a snippet in a different directory.
"#,
)
.await?;
let manifest =
ManifestBuilder::new().add_local_agent("local-agent", "agents/local-agent.md").build();
project.write_manifest(&manifest).await?;
project.run_agpm(&["install"])?.assert_success();
let installed_agent = project.project_path().join(".claude/agents/local-agent.md");
assert!(tokio::fs::metadata(&installed_agent).await.is_ok(), "Local agent should be installed");
let installed_snippet = project.project_path().join(".claude/snippets/utils.md");
assert!(
tokio::fs::metadata(&installed_snippet).await.is_ok(),
"Utils snippet (transitive) should be installed to .claude/snippets (inheriting parent's tool)"
);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains(r#"name = "local-agent""#));
assert!(lockfile_content.contains(r#"name = "utils""#));
assert!(lockfile_content.contains(r#"path = "snippets/utils.md""#));
Ok(())
}
#[tokio::test]
async fn test_local_transitive_missing_file() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let agents_dir = project.project_path().join("agents");
tokio::fs::create_dir_all(&agents_dir).await?;
let local_agent_path = agents_dir.join("local-agent.md");
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
agents:
- path: ./missing.md
---
# Local Agent
This agent has a transitive dependency that doesn't exist.
"#,
)
.await?;
let manifest =
ManifestBuilder::new().add_local_agent("local-agent", "agents/local-agent.md").build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(!output.success, "Install should fail when transitive dependency is missing");
assert!(
output.stderr.contains("Failed to resolve transitive dependency")
|| output.stderr.contains("Failed to fetch resource")
|| output.stderr.contains("file access"),
"Error should indicate transitive dependency failure, got: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_local_transitive_invalid_path_format() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let agents_dir = project.project_path().join("agents");
tokio::fs::create_dir_all(&agents_dir).await?;
let helper_path = agents_dir.join("helper.md");
tokio::fs::write(&helper_path, "# Helper\n").await?;
let local_agent_path = agents_dir.join("local-agent.md");
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
agents:
- path: helper.md
---
# Local Agent
This agent has an invalid transitive dependency path.
"#,
)
.await?;
let manifest =
ManifestBuilder::new().add_local_agent("local-agent", "agents/local-agent.md").build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(!output.success, "Install should fail when transitive dependency path is invalid");
assert!(
output.stderr.contains("Failed to resolve transitive dependency")
|| output.stderr.contains("Failed to fetch resource")
|| output.stderr.contains("file access"),
"Error should indicate transitive dependency failure, got: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_local_transitive_outside_manifest_directory() -> Result<()> {
agpm_cli::test_utils::init_test_logging(None);
let project = TestProject::new().await?;
let project_parent = project.project_path().parent().unwrap();
let shared_dir = project_parent.join("shared");
tokio::fs::create_dir_all(&shared_dir).await?;
let shared_snippet = shared_dir.join("utils.md");
tokio::fs::write(
&shared_snippet,
r#"# Shared Utils
Common utilities shared across projects.
"#,
)
.await?;
let agents_dir = project.project_path().join("agents");
tokio::fs::create_dir_all(&agents_dir).await?;
let relative_to_shared = "../../shared/utils.md";
let agent_path = agents_dir.join("my-agent.md");
tokio::fs::write(
&agent_path,
format!(
r#"---
dependencies:
snippets:
- path: {}
tool: agpm
---
# My Agent
Uses a shared snippet outside the project directory.
"#,
relative_to_shared
),
)
.await?;
let manifest = ManifestBuilder::new().add_local_agent("my-agent", "agents/my-agent.md").build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed with cross-directory transitive dependency");
let installed_agent = project.project_path().join(".claude/agents/my-agent.md");
assert!(tokio::fs::metadata(&installed_agent).await.is_ok(), "Agent should be installed");
let expected_snippet_path = project.project_path().join(".agpm/snippets/shared/utils.md");
assert!(
tokio::fs::metadata(&expected_snippet_path).await.is_ok(),
"Shared snippet should be installed at .agpm/snippets/shared/utils.md"
);
let installed_content = tokio::fs::read(&expected_snippet_path).await?;
let expected_content = b"# Shared Utils\nCommon utilities shared across projects.\n";
assert_eq!(
installed_content, expected_content,
"Installed snippet should have correct content"
);
let lockfile_content = project.read_lockfile().await?;
assert!(lockfile_content.contains(r#"name = "my-agent""#), "Lockfile should contain agent");
assert!(
lockfile_content.contains(r#"path = "../shared/utils.md""#),
"Lockfile should contain manifest-relative path (with ../) for cross-directory dependency.\nLockfile:\n{}",
lockfile_content
);
assert!(
lockfile_content.contains(r#"tool = "agpm""#),
"Lockfile should specify agpm tool for snippet"
);
Ok(())
}