use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{
TreeSource, blob_entries, compress_dir, content_hash, dir_hash, expand_client_entries, generate_marketplace, prune_superseded,
version_dir_name, write_version_dir,
};
use crate::host::Plugin;
const FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/plugin");
fn test_plugin() -> Plugin {
Plugin { name: "ez-test-plugin", marketplace: "ez-test-mkt", version: "0.1.0", agents: &["claude"], instructions: None, blob: &[] }
}
fn fixture_blob() -> Vec<u8> {
compress_dir(Path::new(FIXTURE)).unwrap()
}
fn scratch() -> PathBuf {
let dir = crate::scratch::path("ez-mat");
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn embedded_hash_equals_materialized_hash() {
let plugin = test_plugin();
let blob = fixture_blob();
let mut entries = blob_entries(&blob).unwrap();
expand_client_entries(&mut entries, "claude");
let root = scratch();
let versions = root.join("versions");
std::fs::create_dir_all(&versions).unwrap();
let version_dir = versions.join("0.1.0@claude");
write_version_dir(&plugin, &entries, &versions, &version_dir).unwrap();
assert!(version_dir.join(".claude-plugin/marketplace.json").exists());
assert_eq!(dir_hash(&version_dir).unwrap(), content_hash(TreeSource::Blob(&blob), "claude").unwrap());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn client_token_materializes_per_client_and_hashes_match_baseline() {
let src = scratch();
let cp = src.join(".claude-plugin");
std::fs::create_dir_all(&cp).unwrap();
std::fs::write(cp.join("plugin.json"), br#"{"name":"tok","version":"0.1.0","description":"d","author":{"name":"a"}}"#).unwrap();
std::fs::create_dir_all(src.join("hooks")).unwrap();
std::fs::write(
src.join("hooks").join("hooks.json"),
br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"h --client ${AGENTGEAR_CLIENT}"}]}]}}"#,
)
.unwrap();
let blob = compress_dir(&src).unwrap();
let plugin = Plugin { name: "tok", marketplace: "tok-mkt", version: "0.1.0", agents: &["claude"], instructions: None, blob: &[] };
let root = scratch();
let versions = root.join("versions");
std::fs::create_dir_all(&versions).unwrap();
let mut entries = blob_entries(&blob).unwrap();
expand_client_entries(&mut entries, "claude");
let version_dir = versions.join("0.1.0@claude");
write_version_dir(&plugin, &entries, &versions, &version_dir).unwrap();
let h = std::fs::read_to_string(version_dir.join("hooks/hooks.json")).unwrap();
assert!(h.contains("--client claude"), "token not substituted:\n{h}");
assert!(!h.contains("${AGENTGEAR_CLIENT}"), "raw token survived on disk:\n{h}");
assert_eq!(dir_hash(&version_dir).unwrap(), content_hash(TreeSource::Blob(&blob), "claude").unwrap());
assert_eq!(dir_hash(&version_dir).unwrap(), content_hash(TreeSource::Dir(&src), "claude").unwrap());
assert_ne!(content_hash(TreeSource::Blob(&blob), "claude").unwrap(), content_hash(TreeSource::Blob(&blob), "copilot-cli").unwrap());
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn write_version_dir_is_idempotent_when_target_exists() {
let plugin = test_plugin();
let entries = blob_entries(&fixture_blob()).unwrap();
let root = scratch();
let versions = root.join("versions");
std::fs::create_dir_all(&versions).unwrap();
let version_dir = versions.join("0.1.0@claude");
write_version_dir(&plugin, &entries, &versions, &version_dir).unwrap();
let first = dir_hash(&version_dir).unwrap();
write_version_dir(&plugin, &entries, &versions, &version_dir).unwrap();
assert_eq!(dir_hash(&version_dir).unwrap(), first);
let temps: Vec<_> = std::fs::read_dir(&versions)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(temps.is_empty(), "leftover temp dirs: {temps:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn one_changed_byte_at_an_unchanged_version_keys_a_new_dir() {
let src = scratch();
let cp = src.join(".claude-plugin");
std::fs::create_dir_all(&cp).unwrap();
std::fs::write(cp.join("plugin.json"), br#"{"name":"drift","version":"0.1.0","description":"d","author":{"name":"a"}}"#).unwrap();
std::fs::create_dir_all(src.join("hooks")).unwrap();
std::fs::write(src.join("hooks").join("hooks.json"), b"{\"hooks\":{}}").unwrap();
let before = content_hash(TreeSource::Dir(&src), "claude").unwrap();
std::fs::write(src.join("hooks").join("hooks.json"), b"{\"hooks\":{ }}").unwrap();
let after = content_hash(TreeSource::Dir(&src), "claude").unwrap();
assert_ne!(before, after, "a changed byte must change the tree hash");
assert_ne!(
version_dir_name("0.1.0", &before, "claude"),
version_dir_name("0.1.0", &after, "claude"),
"a changed tree must key a different version dir at the same version"
);
std::fs::remove_dir_all(&src).ok();
}
#[test]
fn prune_drops_this_versions_other_variants_and_keeps_everything_else() {
let versions = scratch();
let keep = version_dir_name("0.1.0", &"a".repeat(64), "claude");
let superseded = version_dir_name("0.1.0", &"b".repeat(64), "claude");
let legacy = "0.1.0@claude".to_string();
let other_client = version_dir_name("0.1.0", &"c".repeat(64), "copilot-cli");
let other_version = version_dir_name("0.2.0", &"d".repeat(64), "claude");
let prerelease = version_dir_name("0.1.0-rc.1", &"e".repeat(64), "claude");
let temp = "0.1.0.tmp.deadbeef.42".to_string();
let all = [&keep, &superseded, &legacy, &other_client, &other_version, &prerelease, &temp];
for name in all {
std::fs::create_dir_all(versions.join(name)).unwrap();
}
prune_superseded(&versions, "0.1.0", "claude", &keep);
for name in [&superseded, &legacy] {
assert!(!versions.join(name).exists(), "{name} should have been pruned");
}
for name in [&keep, &other_client, &other_version, &prerelease, &temp] {
assert!(versions.join(name).exists(), "{name} must survive the prune");
}
std::fs::remove_dir_all(&versions).ok();
}
#[test]
fn generated_marketplace_has_no_version_and_no_bom() {
let plugin = test_plugin();
let entries = blob_entries(&fixture_blob()).unwrap();
let bytes = generate_marketplace(&plugin, &entries).unwrap();
assert_ne!(bytes.first(), Some(&0xEF), "must not start with a UTF-8 BOM");
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["name"], "ez-test-mkt");
assert!(value.get("version").is_none(), "marketplace entry must not carry a version");
assert!(value["description"].is_string());
assert!(value["owner"]["name"].is_string());
assert_eq!(value["plugins"][0]["source"], "./");
assert_eq!(value["plugins"][0]["name"], "ez-test-plugin");
}
#[test]
fn blob_roundtrips_and_compresses_repetitive_input() {
let dir = scratch();
let cp = dir.join(".claude-plugin");
std::fs::create_dir_all(&cp).unwrap();
std::fs::write(cp.join("plugin.json"), br#"{"name":"x","version":"0.1.0","description":"d","author":{"name":"a"}}"#).unwrap();
let big = "abcdefgh".repeat(8192); std::fs::write(dir.join("big.txt"), big.as_bytes()).unwrap();
let blob = compress_dir(&dir).unwrap();
assert!(blob.len() * 4 < big.len(), "blob {} not materially smaller than raw {}", blob.len(), big.len());
let entries = blob_entries(&blob).unwrap();
let got = entries.iter().find(|(rel, _)| rel == "big.txt").map(|(_, b)| b.clone()).unwrap();
assert_eq!(got, big.as_bytes());
std::fs::remove_dir_all(&dir).ok();
}