agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
use tokio::fs;

use crate::common::{DirAssert, FileAssert, ManifestBuilder, TestProject};
use crate::fixtures::ManifestFixture;

/// Test installing from a manifest when no lockfile exists
#[tokio::test]
async fn test_install_creates_lockfile() {
    let project = TestProject::new().await.unwrap();

    // Create mock source repositories - can reuse standard repo with different dependency names
    let (_official_repo, official_url) = project.create_standard_v1_repo("official").await.unwrap();
    let community_repo = project.create_source_repo("community").await.unwrap();
    // Create a different agent to avoid path conflicts
    community_repo
        .add_resource("agents", "helper", "# Helper Agent\n\nA helper agent")
        .await
        .unwrap();
    community_repo.commit_all("Add helper").unwrap();
    community_repo.tag_version("v1.0.0").unwrap();
    let community_url = community_repo.bare_file_url(project.sources_path()).await.unwrap();

    // Create manifest using ManifestBuilder
    let manifest = ManifestBuilder::new()
        .add_sources(&[("official", &official_url), ("community", &community_url)])
        .add_standard_agent("my-agent", "official", "agents/test-agent.md")
        .add_standard_agent("helper", "community", "agents/helper.md")
        .build();
    project.write_manifest(&manifest).await.unwrap();

    // Run install command
    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    output.assert_success();
    assert!(
        output.stdout.contains("Installing")
            || output.stdout.contains("Cloning")
            || output.stdout.contains("Installed"),
        "Expected install progress message, got: {}",
        output.stdout
    );

    // Verify lockfile was created
    let lockfile_path = project.project_path().join("agpm.lock");
    FileAssert::exists(&lockfile_path).await;

    // Verify lockfile content structure
    let lockfile_content = fs::read_to_string(&lockfile_path).await.unwrap();
    assert!(lockfile_content.contains("version = 1"));
    assert!(lockfile_content.contains("[[sources]]"));
    assert!(lockfile_content.contains("[[agents]]"));
    assert!(lockfile_content.contains("my-agent"));
    assert!(lockfile_content.contains("helper"));

    // Verify agents were installed
    let agents_dir = project.project_path().join(".claude/agents/agpm");
    assert!(agents_dir.join("test-agent.md").exists());
    assert!(agents_dir.join("helper.md").exists());
}

/// Test installing when lockfile already exists
#[tokio::test]
async fn test_install_with_existing_lockfile() {
    let project = TestProject::new().await.unwrap();

    // Create mock source repositories
    let official_repo = project.create_source_repo("official").await.unwrap();
    official_repo.add_resource("agents", "my-agent", "# My Agent\n\nA test agent").await.unwrap();
    official_repo.commit_all("Add my agent").unwrap();
    official_repo.tag_version("v1.0.0").unwrap();
    let official_url = official_repo.bare_file_url(project.sources_path()).await.unwrap();
    let official_sha = official_repo.git.get_commit_hash().unwrap();

    let community_repo = project.create_source_repo("community").await.unwrap();
    community_repo
        .add_resource("agents", "helper", "# Helper Agent\n\nA helper agent")
        .await
        .unwrap();
    community_repo.commit_all("Add helper agent").unwrap();
    community_repo.tag_version("v1.0.0").unwrap();
    let community_url = community_repo.bare_file_url(project.sources_path()).await.unwrap();
    let community_sha = community_repo.git.get_commit_hash().unwrap();

    // Create manifest using ManifestBuilder
    let manifest = ManifestBuilder::new()
        .add_sources(&[("official", &official_url), ("community", &community_url)])
        .add_standard_agent("my-agent", "official", "agents/my-agent.md")
        .add_standard_agent("helper", "community", "agents/helper.md")
        .build();
    project.write_manifest(&manifest).await.unwrap();

    // Create a matching lockfile with the actual commit SHAs
    let lockfile_content = format!(
        r#"# Auto-generated lockfile - DO NOT EDIT
version = 1

[[sources]]
name = "official"
url = "{}"
commit = "{}"
fetched_at = "2024-01-01T00:00:00Z"

[[sources]]
name = "community"
url = "{}"
commit = "{}"
fetched_at = "2024-01-01T00:00:00Z"

[[agents]]
name = "my-agent"
source = "official"
url = "{}"
path = "agents/my-agent.md"
version = "v1.0.0"
resolved_commit = "{}"
checksum = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
installed_at = ".claude/agents/agpm/my-agent.md"
artifact_type = "claude-code"

[[agents]]
name = "helper"
source = "community"
url = "{}"
path = "agents/helper.md"
version = "v1.0.0"
resolved_commit = "{}"
checksum = "sha256:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da"
installed_at = ".claude/agents/agpm/helper.md"
artifact_type = "claude-code"
"#,
        official_url,
        official_sha,
        community_url,
        community_sha,
        official_url,
        official_sha, // my-agent url and commit
        community_url,
        community_sha // helper url and commit
    );

    fs::write(project.project_path().join("agpm.lock"), lockfile_content).await.unwrap();

    // Run install command (with --frozen to use existing lockfile without updating)
    let output = project.run_agpm(&["install", "--frozen"]).unwrap();
    output.assert_success();
    assert!(
        output.stdout.contains("Installing")
            || output.stdout.contains("Cloning")
            || output.stdout.contains("Installed"),
        "Expected install progress message, got: {}",
        output.stdout
    );

    // Verify agents directory was created and populated (prefix stripped)
    let agents_dir = project.project_path().join(".claude").join("agents").join("agpm");
    DirAssert::exists(&agents_dir).await;
    DirAssert::contains_file(&agents_dir, "my-agent.md").await;
    DirAssert::contains_file(&agents_dir, "helper.md").await;
}

/// Test install command without agpm.toml
#[tokio::test]
async fn test_install_without_manifest() {
    let project = TestProject::new().await.unwrap();

    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    assert!(!output.success, "Expected command to fail but it succeeded");
    assert!(
        output.stderr.contains("No agpm.toml found"),
        "Expected manifest not found error, got: {}",
        output.stderr
    );
}

/// Test install with invalid manifest syntax
#[tokio::test]
async fn test_install_invalid_manifest_syntax() {
    let project = TestProject::new().await.unwrap();
    let manifest_content = ManifestFixture::invalid_syntax().content;
    project.write_manifest(&manifest_content).await.unwrap();

    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    assert!(!output.success, "Expected command to fail but it succeeded");
    assert!(
        output.stderr.contains("Invalid manifest file syntax"),
        "Expected syntax error, got: {}",
        output.stderr
    );
}

/// Test install with missing required fields in manifest
#[tokio::test]
async fn test_install_missing_manifest_fields() {
    let project = TestProject::new().await.unwrap();
    let manifest_content = ManifestFixture::missing_fields().content;
    project.write_manifest(&manifest_content).await.unwrap();

    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    assert!(!output.success, "Expected command to fail but it succeeded");
    assert!(
        output.stderr.contains("Missing required field"),
        "Expected missing field error, got: {}",
        output.stderr
    );
}

/// Test install with local dependencies
#[tokio::test]
async fn test_install_local_dependencies() {
    let project = TestProject::new().await.unwrap();

    // Create local files referenced in manifest
    // The manifest expects ../local-agents/helper.md relative to project
    let parent_dir = project.project_path().parent().unwrap();
    let local_agents_dir = parent_dir.join("local-agents");
    fs::create_dir_all(&local_agents_dir).await.unwrap();
    fs::write(local_agents_dir.join("helper.md"), "# Local Agent Helper\n\nThis is a local agent.")
        .await
        .unwrap();

    // Create local snippet in project directory
    project
        .create_local_resource(
            "snippets/local-utils.md",
            "# Local Utils\n\nThis is a local snippet.",
        )
        .await
        .unwrap();

    // Add official source for the remote dependency using new helper
    let (_official_repo, official_url) = project.create_standard_v1_repo("official").await.unwrap();

    // Create manifest with ManifestBuilder showing local + remote dependencies
    let manifest = ManifestBuilder::new()
        .add_source("official", &official_url)
        .add_standard_agent("my-agent", "official", "agents/test-agent.md")
        .add_agent("local-agent", |d| d.path("../local-agents/helper.md").flatten(false))
        .add_local_snippet("local-utils", "./snippets/local-utils.md")
        .build();
    project.write_manifest(&manifest).await.unwrap();

    // Run install command
    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    output.assert_success();
    assert!(
        output.stdout.contains("Installing")
            || output.stdout.contains("Cloning")
            || output.stdout.contains("Installed"),
        "Expected install progress message, got: {}",
        output.stdout
    );

    // Verify lockfile was created and contains all dependencies
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();
    assert!(lockfile_content.contains("my-agent")); // remote dependency
    assert!(lockfile_content.contains("local-agent")); // local dependency
    assert!(lockfile_content.contains("local-utils")); // local dependency

    // Verify all dependencies were installed
    let agents_dir = project.project_path().join(".claude").join("agents").join("agpm");
    assert!(agents_dir.join("test-agent.md").exists()); // From create_standard_v1_repo

    // Local files preserve their relative path structure (after stripping ../)
    // ../local-agents/helper.md becomes local-agents/helper.md, installed to .claude/agents/local-agents/helper.md
    let local_helper_path =
        project.project_path().join(".claude/agents/agpm/local-agents/helper.md");
    assert!(local_helper_path.exists(), "Local helper should be at {:?}", local_helper_path);

    // Snippets now default to .agpm/snippets (agpm artifact type)
    // ./snippets/local-utils.md has ./ stripped, then snippets/ stripped → local-utils.md
    let local_utils_path = project.project_path().join(".agpm/snippets/local-utils.md");
    assert!(local_utils_path.exists(), "Local utils should be at {:?}", local_utils_path);
}

/// Test install with network simulation failure
#[tokio::test]
async fn test_install_network_failure() {
    let project = TestProject::new().await.unwrap();

    // Create a manifest with non-existent local sources to simulate failure
    let manifest_content = r#"
[sources]
official = "file:///non/existent/path/to/repo"

[agents]
my-agent = { source = "official", path = "agents/my-agent.md", version = "v1.0.0" }
"#;
    project.write_manifest(manifest_content).await.unwrap();

    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    assert!(!output.success, "Expected command to fail but it succeeded");
    assert!(
        output.stderr.contains("Failed to clone")
            || output.stderr.contains("does not exist")
            || output.stderr.contains("Local repository path does not exist"),
        "Expected clone failure error, got: {}",
        output.stderr
    );
}

/// Test install with corrupted lockfile
#[tokio::test]
async fn test_install_corrupted_lockfile() {
    let project = TestProject::new().await.unwrap();
    let manifest_content = ManifestFixture::basic().content;
    project.write_manifest(&manifest_content).await.unwrap();

    // Create corrupted lockfile
    fs::write(project.project_path().join("agpm.lock"), "corrupted content").await.unwrap();

    let output = project.run_agpm(&["install", "--no-cache"]).unwrap();
    assert!(!output.success, "Expected command to fail but it succeeded");
    assert!(
        output.stderr.contains("Invalid or corrupted lockfile detected")
            || output.stderr.contains("Invalid lockfile syntax")
            || output.stderr.contains("Failed to parse lockfile"),
        "Expected lockfile error, got: {}",
        output.stderr
    );
}