agr 0.1.1

agr — install agent artifacts from the public registry into a project
Documentation
//! Integration tests for `agr` install/update/uninstall against a mock registry
//! server. Self-contained (no gateway, no Postgres): a tiny axum server serves
//! an install manifest and the raw files, with a bumpable version to exercise
//! `update`.

use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

use axum::Router;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use serde_json::json;

use registry_cli::client::RegistryClient;
use registry_cli::lockfile::Lockfile;
use registry_cli::ops;

struct Mock {
    base: String,
    version: Arc<AtomicU32>,
}

async fn spawn_mock() -> Mock {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr: SocketAddr = listener.local_addr().unwrap();
    let base = format!("http://{addr}");
    let version = Arc::new(AtomicU32::new(0));

    #[derive(Clone)]
    struct St {
        base: String,
        version: Arc<AtomicU32>,
    }
    let st = St {
        base: base.clone(),
        version: version.clone(),
    };

    async fn manifest(State(st): State<St>) -> impl IntoResponse {
        let v = st.version.load(Ordering::SeqCst);
        axum::Json(json!({
            "handle": "@acme/demo",
            "target": "claude",
            "version": format!("git-v{v}"),
            "source_ref": "main",
            "pinned": false,
            "files": [
                { "source_url": format!("{}/raw/SKILL.md", st.base),
                  "dest_path": ".claude/skills/demo/SKILL.md" }
            ]
        }))
    }

    async fn raw(State(st): State<St>) -> impl IntoResponse {
        let v = st.version.load(Ordering::SeqCst);
        format!("skill content v{v}")
    }

    let app = Router::new()
        .route(
            "/api/v1/artifacts/:ns/:slug/install-manifest",
            get(manifest),
        )
        .route("/raw/SKILL.md", get(raw))
        .with_state(st);

    tokio::spawn(async move {
        let _ = axum::serve(listener, app).await;
    });

    Mock { base, version }
}

fn temp_dir() -> PathBuf {
    static N: AtomicU32 = AtomicU32::new(0);
    let dir = std::env::temp_dir().join(format!(
        "agr-test-{}-{}",
        std::process::id(),
        N.fetch_add(1, Ordering::SeqCst)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

#[tokio::test]
async fn install_writes_files_and_lockfile_then_noop_then_force() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let skill = dir.join(".claude/skills/demo/SKILL.md");

    // First install lands the file + lockfile.
    let s = ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    assert!(!s.skipped);
    assert_eq!(s.written, vec![".claude/skills/demo/SKILL.md"]);
    assert_eq!(std::fs::read_to_string(&skill).unwrap(), "skill content v0");
    assert!(lock.exists());
    let lf = Lockfile::load(&lock).unwrap();
    assert_eq!(lf.artifacts.len(), 1);
    assert_eq!(lf.get("@acme/demo", "claude").unwrap().version, "git-v0");

    // Second install at the same version is a no-op.
    let s = ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    assert!(s.skipped, "re-install at same version must be a no-op");

    // A foreign file blocks install without --force.
    let dir2 = temp_dir();
    let foreign = dir2.join(".claude/skills/demo/SKILL.md");
    std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
    std::fs::write(&foreign, "hand-written").unwrap();
    let err = ops::install(
        &client,
        "@acme/demo",
        "claude",
        &dir2,
        false,
        &dir2.join("agr.lock"),
    )
    .await
    .unwrap_err();
    assert!(matches!(err, registry_cli::CliError::WouldOverwrite(_)));
    // --force overwrites it.
    let s = ops::install(
        &client,
        "@acme/demo",
        "claude",
        &dir2,
        true,
        &dir2.join("agr.lock"),
    )
    .await
    .unwrap();
    assert!(!s.skipped);
    assert_eq!(
        std::fs::read_to_string(&foreign).unwrap(),
        "skill content v0"
    );
}

#[tokio::test]
async fn uninstall_removes_files_lockfile_entry_and_empty_dirs() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let skill = dir.join(".claude/skills/demo/SKILL.md");

    ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    assert!(skill.exists());

    let s = ops::uninstall(&dir, &lock, "@acme/demo", "claude", false).unwrap();
    assert_eq!(s.removed, vec![".claude/skills/demo/SKILL.md"]);
    assert!(s.missing.is_empty());
    assert!(!skill.exists());
    // The directories the install created are gone too, up to the install root.
    assert!(!dir.join(".claude/skills/demo").exists());
    assert!(!dir.join(".claude").exists());
    assert!(dir.exists(), "the install root itself is never removed");
    assert!(Lockfile::load(&lock).unwrap().artifacts.is_empty());

    // Handles resolve with or without the leading `@`.
    let err = ops::uninstall(&dir, &lock, "acme/demo", "claude", false).unwrap_err();
    assert!(matches!(err, registry_cli::CliError::NotInstalled { .. }));
}

#[tokio::test]
async fn uninstall_refuses_to_discard_local_edits_without_force() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let skill = dir.join(".claude/skills/demo/SKILL.md");

    ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    std::fs::write(&skill, "locally edited").unwrap();

    let err = ops::uninstall(&dir, &lock, "@acme/demo", "claude", false).unwrap_err();
    assert!(matches!(
        err,
        registry_cli::CliError::LocallyModified { .. }
    ));
    assert!(skill.exists(), "a refused uninstall must not delete");
    assert!(!Lockfile::load(&lock).unwrap().artifacts.is_empty());

    // --force goes through.
    let s = ops::uninstall(&dir, &lock, "@acme/demo", "claude", true).unwrap();
    assert_eq!(s.removed.len(), 1);
    assert!(!skill.exists());
}

#[tokio::test]
async fn uninstall_reports_files_already_deleted_by_hand() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");

    ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    std::fs::remove_file(dir.join(".claude/skills/demo/SKILL.md")).unwrap();

    // A partially-removed install cannot be verified, so it needs --force.
    let err = ops::uninstall(&dir, &lock, "@acme/demo", "claude", false).unwrap_err();
    assert!(matches!(
        err,
        registry_cli::CliError::LocallyModified { .. }
    ));

    let s = ops::uninstall(&dir, &lock, "@acme/demo", "claude", true).unwrap();
    assert!(s.removed.is_empty());
    assert_eq!(s.missing, vec![".claude/skills/demo/SKILL.md"]);
    assert!(Lockfile::load(&lock).unwrap().artifacts.is_empty());
}

#[tokio::test]
async fn uninstall_rejects_a_lockfile_path_that_escapes_the_install_dir() {
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let outside = dir.join("outside.txt");
    std::fs::write(&outside, "do not touch").unwrap();

    // A hand-edited (or hostile) lockfile pointing outside the project.
    std::fs::write(
        &lock,
        "version = 1\n\n[[artifact]]\nhandle = \"@acme/demo\"\nversion = \"git-v0\"\n\
         target = \"claude\"\ncontent_hash = \"deadbeef\"\nfiles = [\"../outside.txt\"]\n",
    )
    .unwrap();

    let nested = dir.join("project");
    std::fs::create_dir_all(&nested).unwrap();
    let err = ops::uninstall(&nested, &lock, "@acme/demo", "claude", true).unwrap_err();
    assert!(matches!(err, registry_cli::CliError::UnsafePath(_)));
    assert!(outside.exists(), "must not delete outside the install dir");
}

#[tokio::test]
async fn update_will_not_overwrite_a_locally_edited_artifact() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let skill = dir.join(".claude/skills/demo/SKILL.md");

    ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();
    std::fs::write(&skill, "hand-edited, do not clobber").unwrap();
    mock.version.store(1, Ordering::SeqCst);

    // The update is reported, but the edited file survives and the lockfile
    // still points at the old version.
    let report = ops::update(&client, &dir, &lock, true, false)
        .await
        .unwrap();
    assert_eq!(report.changes.len(), 1);
    assert_eq!(report.skipped.len(), 1);
    assert_eq!(report.skipped[0].handle, "@acme/demo");
    assert_eq!(
        std::fs::read_to_string(&skill).unwrap(),
        "hand-edited, do not clobber"
    );
    assert_eq!(
        Lockfile::load(&lock)
            .unwrap()
            .get("@acme/demo", "claude")
            .unwrap()
            .version,
        "git-v0"
    );

    // --force takes the upstream version.
    let report = ops::update(&client, &dir, &lock, true, true).await.unwrap();
    assert!(report.skipped.is_empty());
    assert_eq!(std::fs::read_to_string(&skill).unwrap(), "skill content v1");
}

#[tokio::test]
async fn update_detects_and_applies_upstream_change() {
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);
    let dir = temp_dir();
    let lock = dir.join("agr.lock");
    let skill = dir.join(".claude/skills/demo/SKILL.md");

    ops::install(&client, "@acme/demo", "claude", &dir, false, &lock)
        .await
        .unwrap();

    // Simulate an upstream bump.
    mock.version.store(1, Ordering::SeqCst);

    // Dry run reports the change but doesn't touch files.
    let report = ops::update(&client, &dir, &lock, false, false)
        .await
        .unwrap();
    assert_eq!(report.changes.len(), 1);
    assert_eq!(report.changes[0].from, "git-v0");
    assert_eq!(report.changes[0].to, "git-v1");
    assert!(!report.applied);
    assert_eq!(std::fs::read_to_string(&skill).unwrap(), "skill content v0");

    // Apply rewrites the file and bumps the lockfile.
    let report = ops::update(&client, &dir, &lock, true, false)
        .await
        .unwrap();
    assert!(report.applied);
    assert_eq!(std::fs::read_to_string(&skill).unwrap(), "skill content v1");
    assert_eq!(
        Lockfile::load(&lock)
            .unwrap()
            .get("@acme/demo", "claude")
            .unwrap()
            .version,
        "git-v1"
    );

    // Now up to date.
    let report = ops::update(&client, &dir, &lock, false, false)
        .await
        .unwrap();
    assert!(report.changes.is_empty());
}

#[tokio::test]
async fn a_source_download_failure_is_not_reported_as_a_registry_error() {
    // D6: install downloads come from the artifact's source host, not from the
    // registry. Blaming the registry for a raw.githubusercontent.com 404 sends
    // whoever is debugging to the wrong system.
    let mock = spawn_mock().await;
    let client = RegistryClient::new(&mock.base);

    let url = format!("{}/raw/MISSING.md", mock.base);
    let err = client.fetch_file(&url).await.unwrap_err();

    assert!(
        matches!(err, registry_cli::CliError::Download { status: 404, .. }),
        "err: {err:?}"
    );
    let msg = err.to_string();
    assert!(msg.contains("download failed (404)"), "message: {msg}");
    assert!(msg.contains("/raw/MISSING.md"), "message: {msg}");
    assert!(!msg.contains("registry API"), "message: {msg}");
}