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");
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");
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");
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(_)));
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());
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());
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());
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();
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();
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);
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"
);
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();
mock.version.store(1, Ordering::SeqCst);
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");
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"
);
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() {
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}");
}