use anyhow::Result;
use toml_edit::DocumentMut;
use crate::common::{ManifestBuilder, TestProject};
#[tokio::test]
async fn test_local_file_change_triggers_reinstall() -> Result<()> {
let project = TestProject::new().await?;
let local_agent_path = project.project_path().join("local-agent.md");
tokio::fs::write(&local_agent_path, "# Local Agent v1\n\nOriginal 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", "--quiet"])?;
assert!(output.success, "Initial install failed: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/local-agent.md");
let content_v1 = tokio::fs::read_to_string(&installed_path).await?;
assert!(
content_v1.contains("Original content"),
"Initial install should have original content"
);
tokio::fs::write(&local_agent_path, "# Local Agent v2\n\nUpdated content.").await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let content_v2 = tokio::fs::read_to_string(&installed_path).await?;
assert!(
content_v2.contains("Updated content"),
"Second install should have updated content. Got: {}",
content_v2
);
Ok(())
}
#[tokio::test]
async fn test_local_file_added_transitive_triggers_resolution() -> Result<()> {
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("main-agent.md");
tokio::fs::write(&local_agent_path, "# Main Agent\n\nNo dependencies yet.").await?;
let manifest =
ManifestBuilder::new().add_local_agent("main-agent", "agents/main-agent.md").build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Initial install failed: {}", output.stderr);
let installed_main = project.project_path().join(".claude/agents/agpm/main-agent.md");
assert!(installed_main.exists(), "Main agent should be installed");
let installed_helper = project.project_path().join(".claude/agents/agpm/helper.md");
assert!(!installed_helper.exists(), "Helper should NOT exist yet");
let helper_path = agents_dir.join("helper.md");
tokio::fs::write(&helper_path, "# Helper Agent\n\nA helper agent.").await?;
tokio::fs::write(
&local_agent_path,
r#"---
dependencies:
agents:
- path: ./helper.md
---
# Main Agent
Now with a transitive dependency.
"#,
)
.await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
assert!(
installed_helper.exists(),
"Helper agent should be installed after adding transitive dep"
);
let lockfile = project.read_lockfile().await?;
assert!(
lockfile.contains(r#"name = "agents/main-agent""#),
"Lockfile should contain main-agent"
);
assert!(
lockfile.contains(r#"name = "agents/helper""#),
"Lockfile should contain helper (transitive)"
);
Ok(())
}
#[tokio::test]
async fn test_branch_ref_update_triggers_reinstall() -> Result<()> {
use agpm_cli::utils::normalize_path_for_storage;
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo
.add_resource("agents", "branch-agent", "# Branch Agent v1\n\nInitial content.")
.await?;
source_repo.commit_all("Initial version")?;
source_repo.git.ensure_branch("main")?;
let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_agent("branch-agent", |d| {
d.source("test-source").path("agents/branch-agent.md").version("main")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Initial install failed: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/branch-agent.md");
let content_v1 = tokio::fs::read_to_string(&installed_path).await?;
assert!(content_v1.contains("Initial content"), "Initial install should have initial content");
source_repo
.add_resource("agents", "branch-agent", "# Branch Agent v2\n\nUpdated content.")
.await?;
source_repo.commit_all("Updated version")?;
let output = project.run_agpm(&["install", "--quiet", "--no-cache"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let content_v2 = tokio::fs::read_to_string(&installed_path).await?;
assert!(
content_v2.contains("Updated content"),
"Second install should have updated content. Got: {}",
content_v2
);
Ok(())
}
#[tokio::test]
async fn test_mutable_deps_flag_prevents_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let local_agent_path = project.project_path().join("local-agent.md");
tokio::fs::write(&local_agent_path, "# Local Agent\n\nContent.").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", "--quiet"])?;
assert!(output.success, "Install failed: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains("has_mutable_deps = true"),
"Lockfile should have has_mutable_deps = true for local deps. Lockfile:\n{}",
lockfile_content
);
Ok(())
}
#[tokio::test]
async fn test_immutable_deps_enable_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "immutable-agent", "# Immutable Agent\n\nContent.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("immutable-agent", "test-source", "agents/immutable-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Install failed: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains("has_mutable_deps = false"),
"Lockfile should have has_mutable_deps = false for versioned deps. Lockfile:\n{}",
lockfile_content
);
Ok(())
}
#[tokio::test]
async fn test_missing_file_triggers_reinstall_on_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "test-agent", "# Test Agent\n\nContent.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("test-agent", "test-source", "agents/test-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Initial install failed: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/test-agent.md");
assert!(installed_path.exists(), "Agent should be installed");
tokio::fs::remove_file(&installed_path).await?;
assert!(!installed_path.exists(), "File should be deleted");
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
assert!(installed_path.exists(), "Agent should be reinstalled after deletion");
Ok(())
}
#[tokio::test]
async fn test_fast_path_stores_manifest_hash() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "test-agent", "# Test Agent\n\nContent.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("test-agent", "test-source", "agents/test-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Install failed: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains("manifest_hash = \"sha256:"),
"Lockfile should contain manifest_hash. Lockfile:\n{}",
lockfile_content
);
Ok(())
}
#[tokio::test]
async fn test_fast_path_invalidated_by_manifest_change() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "agent-one", "# Agent One\n\nFirst agent.").await?;
source_repo.add_resource("agents", "agent-two", "# Agent Two\n\nSecond agent.").await?;
source_repo.commit_all("Add agents")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest1 = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("agent-one", "test-source", "agents/agent-one.md")
.build();
project.write_manifest(&manifest1).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let lockfile1 = project.read_lockfile().await?;
let hash1_line = lockfile1.lines().find(|l| l.contains("manifest_hash")).unwrap();
let manifest2 = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("agent-one", "test-source", "agents/agent-one.md")
.add_standard_agent("agent-two", "test-source", "agents/agent-two.md")
.build();
project.write_manifest(&manifest2).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let lockfile2 = project.read_lockfile().await?;
let hash2_line = lockfile2.lines().find(|l| l.contains("manifest_hash")).unwrap();
assert_ne!(hash1_line, hash2_line, "manifest_hash should change when deps are added");
let agent_two_path = project.project_path().join(".claude/agents/agpm/agent-two.md");
assert!(agent_two_path.exists(), "Second agent should be installed");
Ok(())
}
#[tokio::test]
async fn test_rev_pinned_deps_enable_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "sha-agent", "# SHA Agent\n\nPinned content.").await?;
source_repo.commit_all("Add agent")?;
let sha = source_repo.git.get_commit_hash()?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_agent("sha-agent", |d| d.source("test-source").path("agents/sha-agent.md").rev(&sha))
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Install failed: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains("has_mutable_deps = false"),
"Lockfile should have has_mutable_deps = false for rev-pinned deps. Lockfile:\n{}",
lockfile_content
);
Ok(())
}
#[tokio::test]
async fn test_mixed_mutable_immutable_deps() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "remote-agent", "# Remote Agent\n\nContent.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let local_agent_path = project.project_path().join("local-agent.md");
tokio::fs::write(&local_agent_path, "# Local Agent\n\nContent.").await?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("remote-agent", "test-source", "agents/remote-agent.md")
.add_local_agent("local-agent", "local-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Install failed: {}", output.stderr);
let lockfile_content = project.read_lockfile().await?;
assert!(
lockfile_content.contains("has_mutable_deps = true"),
"Lockfile should have has_mutable_deps = true when mixed deps. Lockfile:\n{}",
lockfile_content
);
Ok(())
}
#[tokio::test]
async fn test_ultra_fast_path_preserves_file_mtime() -> Result<()> {
use std::time::Duration;
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "test-agent", "# Test Agent\n\nContent.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("test-agent", "test-source", "agents/test-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/test-agent.md");
let mtime_after_first = tokio::fs::metadata(&installed_path).await?.modified()?;
const MTIME_RESOLUTION_MS: u64 = 2100;
tokio::time::sleep(Duration::from_millis(MTIME_RESOLUTION_MS)).await;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let mtime_after_second = tokio::fs::metadata(&installed_path).await?.modified()?;
assert_eq!(
mtime_after_first, mtime_after_second,
"Ultra-fast path should NOT modify existing files"
);
Ok(())
}
#[tokio::test]
async fn test_ultra_fast_path_preserves_existing_files() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo
.add_resource("agents", "trusted-agent", "# Trusted Agent\n\nOriginal content.")
.await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("trusted-agent", "test-source", "agents/trusted-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/trusted-agent.md");
tokio::fs::write(&installed_path, "# CORRUPTED\n\nThis file was modified.").await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let content = tokio::fs::read_to_string(&installed_path).await?;
assert!(
content.contains("CORRUPTED"),
"Ultra-fast path should NOT reinstall existing files. Content: {}",
content
);
Ok(())
}
#[tokio::test]
async fn test_lockfile_resource_count_prevents_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "agent-one", "# Agent One\n\nFirst.").await?;
source_repo.add_resource("agents", "agent-two", "# Agent Two\n\nSecond.").await?;
source_repo.commit_all("Add agents")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("agent-one", "test-source", "agents/agent-one.md")
.add_standard_agent("agent-two", "test-source", "agents/agent-two.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let agent_one = project.project_path().join(".claude/agents/agpm/agent-one.md");
let agent_two = project.project_path().join(".claude/agents/agpm/agent-two.md");
assert!(agent_one.exists(), "Agent one should be installed");
assert!(agent_two.exists(), "Agent two should be installed");
let lockfile_path = project.project_path().join("agpm.lock");
let lockfile_content = tokio::fs::read_to_string(&lockfile_path).await?;
let mut doc: DocumentMut = lockfile_content.parse()?;
if let Some(agents) = doc.get_mut("agents").and_then(|a| a.as_array_of_tables_mut()) {
agents.retain(|t| t.get("name").and_then(|n| n.as_str()) == Some("agents/agent-one"));
}
tokio::fs::write(&lockfile_path, doc.to_string()).await?;
tokio::fs::remove_file(&agent_two).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
assert!(agent_two.exists(), "Agent two should be reinstalled after lockfile count validation");
Ok(())
}
#[tokio::test]
async fn test_local_file_deleted_produces_error() -> Result<()> {
let project = TestProject::new().await?;
let local_path = project.project_path().join("local-agent.md");
tokio::fs::write(&local_path, "# Local Agent\n\nContent here.").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", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
tokio::fs::remove_file(&local_path).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(!output.success, "Install should fail when local file is missing");
assert!(
output.stderr.contains("not found")
|| output.stderr.contains("No such file")
|| output.stderr.contains("does not exist")
|| output.stderr.contains("path exists"), "Error should mention missing file. stderr: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_fast_path_with_pattern_dependencies() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "helper-one", "# Helper One\n\nFirst helper.").await?;
source_repo.add_resource("agents", "helper-two", "# Helper Two\n\nSecond helper.").await?;
source_repo.add_resource("agents", "helper-three", "# Helper Three\n\nThird helper.").await?;
source_repo.commit_all("Add agents")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_agent_pattern("all-helpers", "test-source", "agents/helper-*.md", "v1.0.0")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let agents_dir = project.project_path().join(".claude/agents/agpm");
assert!(agents_dir.join("helper-one.md").exists(), "helper-one should be installed");
assert!(agents_dir.join("helper-two.md").exists(), "helper-two should be installed");
assert!(agents_dir.join("helper-three.md").exists(), "helper-three should be installed");
let helper_one = agents_dir.join("helper-one.md");
let mtime_first = tokio::fs::metadata(&helper_one).await?.modified()?;
const MTIME_RESOLUTION_MS: u64 = 2100;
tokio::time::sleep(std::time::Duration::from_millis(MTIME_RESOLUTION_MS)).await;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "Second install failed: {}", output.stderr);
let mtime_second = tokio::fs::metadata(&helper_one).await?.modified()?;
assert_eq!(
mtime_first, mtime_second,
"Fast path should not modify existing pattern-matched files"
);
Ok(())
}
#[tokio::test]
async fn test_frozen_flag_with_fast_path() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("test-source").await?;
source_repo.add_resource("agents", "test-agent", "# Test Agent\n\nOriginal.").await?;
source_repo.commit_all("Add agent")?;
source_repo.tag_version("v1.0.0")?;
let source_url = source_repo.bare_file_url(project.sources_path()).await?;
let manifest = ManifestBuilder::new()
.add_source("test-source", &source_url)
.add_standard_agent("test-agent", "test-source", "agents/test-agent.md")
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install", "--quiet"])?;
assert!(output.success, "First install failed: {}", output.stderr);
let output = project.run_agpm(&["install", "--frozen", "--quiet"])?;
assert!(output.success, "Frozen install should succeed with valid lockfile: {}", output.stderr);
let installed_path = project.project_path().join(".claude/agents/agpm/test-agent.md");
assert!(installed_path.exists(), "Agent should be installed in frozen mode");
let different_url = format!("{}/different-repo.git", project.sources_path().display());
let manifest_with_changed_url = ManifestBuilder::new()
.add_source("test-source", &different_url)
.add_standard_agent("test-agent", "test-source", "agents/test-agent.md")
.build();
project.write_manifest(&manifest_with_changed_url).await?;
let output = project.run_agpm(&["install", "--frozen", "--quiet"])?;
assert!(!output.success, "Frozen install should fail when source URL changed");
assert!(
output.stderr.to_lowercase().contains("source")
|| output.stderr.to_lowercase().contains("url")
|| output.stderr.to_lowercase().contains("changed")
|| output.stderr.to_lowercase().contains("frozen"),
"Error should mention source URL change or frozen mode: {}",
output.stderr
);
Ok(())
}