use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
root: PathBuf,
skill_repo: PathBuf,
project: PathBuf,
spm_home: PathBuf,
}
impl Sandbox {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root =
std::env::temp_dir().join(format!("spm-test-{}-{nanos}-{n}", std::process::id()));
let sb = Sandbox {
skill_repo: root.join("skill"),
project: root.join("project"),
spm_home: root.join("home"),
root,
};
std::fs::create_dir_all(&sb.project).unwrap();
sb.init_skill_repo();
sb
}
fn init_skill_repo(&self) {
std::fs::create_dir_all(&self.skill_repo).unwrap();
std::fs::write(
self.skill_repo.join("SKILL.md"),
"---\nname: greet\ndescription: Say hello nicely.\n---\nGreet warmly.\n",
)
.unwrap();
self.git(&["init", "-q", "-b", "main"]);
self.git(&["add", "-A"]);
self.git(&["commit", "-qm", "initial"]);
self.git(&["tag", "-a", "v0.1.0", "-m", "v0.1.0"]);
}
fn git(&self, args: &[&str]) {
let ok = Command::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgSign=false",
])
.args(args)
.current_dir(&self.skill_repo)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed");
}
fn add_skill_pack(&self) {
for sub in ["alpha", "beta"] {
let dir = self.skill_repo.join("pack").join(sub);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), format!("---\nname: {sub}\n---\n")).unwrap();
}
let bare = self.skill_repo.join("bare");
std::fs::create_dir_all(&bare).unwrap();
std::fs::write(bare.join("README.md"), "not a skill\n").unwrap();
self.git(&["add", "-A"]);
self.git(&["commit", "-qm", "add pack"]);
}
#[cfg(unix)]
fn add_symlinked_skill(&self) {
let dir = self.skill_repo.join("linked");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("real.md"), "---\nname: linked\n---\n").unwrap();
std::os::unix::fs::symlink("real.md", dir.join("SKILL.md")).unwrap();
self.git(&["add", "-A"]);
self.git(&["commit", "-qm", "add symlinked skill"]);
}
fn skill_url(&self) -> String {
format!("file://{}", self.skill_repo.display())
}
fn spm(&self, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_spm"))
.args(args)
.current_dir(&self.project)
.env("SPM_HOME", &self.spm_home)
.output()
.unwrap()
}
fn ok(&self, args: &[&str]) -> String {
let out = self.spm(args);
assert!(
out.status.success(),
"spm {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.project.join(rel)).unwrap()
}
fn claude_market_dir(&self) -> PathBuf {
self.project.join(".spm/claude")
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn skill_head(repo: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn claude_add_resolves_tag_to_commit_and_wires_marketplace() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let lock = sb.read("ai.lock");
assert!(lock.contains("\"reference\": \"tag:v0.1.0\""), "{lock}");
assert!(
lock.contains(&skill_head(&sb.skill_repo)),
"lock should pin repo HEAD commit: {lock}"
);
let skill_md = sb.claude_market_dir().join("plugin/skills/greet/SKILL.md");
assert!(skill_md.exists(), "missing {}", skill_md.display());
let settings = sb.read(".claude/settings.local.json");
assert!(settings.contains("\"spm@spm\": true"), "{settings}");
assert!(settings.contains("extraKnownMarketplaces"), "{settings}");
let gitignore = sb.read(".gitignore");
assert!(gitignore.contains(".spm/"), "{gitignore}");
assert!(gitignore.contains("spm-managed Claude"), "{gitignore}");
assert!(!sb.spm_home.join("vendors").exists());
assert!(!sb.project.join("skills").exists());
}
#[test]
fn copilot_add_materializes_project_local_skills() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--name",
"greet",
]);
let skill_md = sb
.project
.join(".agents/skills/spm-managed-skills/greet/SKILL.md");
assert!(skill_md.exists(), "missing {}", skill_md.display());
assert!(!sb.spm_home.join("vendors/copilot").exists());
let gitignore = sb.read(".gitignore");
assert!(
gitignore.contains(".agents/skills/spm-managed-skills/"),
"{gitignore}"
);
assert!(gitignore.contains("spm-managed"), "{gitignore}");
}
#[test]
fn remove_prunes_skill() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
sb.ok(&["remove", "greet"]);
assert!(!sb.read("ai.json").contains("greet"));
let skills_dir = sb.claude_market_dir().join("plugin/skills");
assert!(std::fs::read_dir(&skills_dir).unwrap().next().is_none());
}
#[test]
fn install_is_idempotent_from_lock() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let lock_before = sb.read("ai.lock");
sb.ok(&["install"]);
let lock_after = sb.read("ai.lock");
assert_eq!(lock_before, lock_after, "install must not change the lock");
}
#[test]
fn clean_removes_generated_config() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
sb.ok(&["clean"]);
let settings = sb.read(".claude/settings.local.json");
assert!(!settings.contains("spm@spm"), "{settings}");
assert!(
!sb.claude_market_dir().exists(),
"marketplace dir should be gone after clean"
);
let gitignore = sb.project.join(".gitignore");
if gitignore.exists() {
let gi = std::fs::read_to_string(&gitignore).unwrap();
assert!(!gi.contains(".spm/"), "{gi}");
}
}
#[test]
fn multi_target_wires_both_vendors() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude,copilot"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
assert!(sb
.claude_market_dir()
.join("plugin/skills/greet/SKILL.md")
.exists());
assert!(sb
.project
.join(".agents/skills/spm-managed-skills/greet/SKILL.md")
.exists());
assert!(sb.read(".claude/settings.local.json").contains("spm@spm"));
}
#[test]
fn unknown_target_is_rejected() {
let sb = Sandbox::new();
let out = sb.spm(&["init", "--target", "nonsense"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown target"));
}
#[test]
fn schema_rejects_unknown_target_value() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["bogus"],"skills":{}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("does not match schema"), "{err}");
}
#[test]
fn schema_rejects_skill_without_version_selector() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"x":{"git":"u"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("oneOf"));
}
#[test]
fn rejects_path_traversal_in_skill_path() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"evil":{"git":"u","tag":"v0.1.0","path":"../../../../../../etc"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "traversal path must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("`..`"), "{err}");
}
#[test]
fn rejects_absolute_skill_path() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"evil":{"git":"u","tag":"v0.1.0","path":"/etc"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "absolute path must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("relative to the repo root"), "{err}");
}
#[test]
fn rejects_path_traversal_in_skill_name() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"../../evil":{"git":"u","tag":"v0.1.0"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "traversal name must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("invalid skill name"), "{err}");
}
#[test]
fn rejects_traversal_name_on_add() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
let out = sb.spm(&[
"add",
&sb.skill_url(),
"--tag",
"v0.1.0",
"--name",
"../escape",
]);
assert!(!out.status.success(), "add must reject a traversal name");
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid skill name"));
assert!(!sb.read("ai.json").contains("escape"));
}
#[test]
fn rejects_forged_absolute_store_in_lock() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{}}"#,
)
.unwrap();
std::fs::write(
sb.project.join("ai.lock"),
r#"{"id":"spm-deadbeef","skills":{"evil":{"git":"u","reference":"branch:main","commit":"0000000000000000000000000000000000000000","store":"/home/victim/.config/autostart"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "forged store must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("store key"));
}
#[test]
fn rejects_forged_project_id_in_lock() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["copilot"],"skills":{}}"#,
)
.unwrap();
std::fs::write(
sb.project.join("ai.lock"),
r#"{"id":"../../evil","skills":{}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "forged id must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid project id"));
}
#[test]
fn schema_rejects_abbreviated_commit() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"x":{"git":"u","commit":"abc1234"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "abbreviated commit must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("does not match schema"));
}
#[test]
fn copilot_clean_removes_project_local_dir_and_gitignore_entry() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--name",
"greet",
]);
let managed = sb.project.join(".agents/skills/spm-managed-skills");
assert!(
managed.exists(),
"skills should be materialized before clean"
);
assert!(sb
.read(".gitignore")
.contains(".agents/skills/spm-managed-skills/"));
sb.ok(&["clean"]);
assert!(
!managed.exists(),
"clean must remove the managed skills dir"
);
assert!(
!sb.read(".gitignore")
.contains(".agents/skills/spm-managed-skills/"),
"clean must drop the gitignore entry"
);
}
#[test]
fn uppercase_commit_pin_is_not_refetched() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
let head = skill_head(&sb.skill_repo).to_uppercase();
sb.ok(&["add", &sb.skill_url(), "--commit", &head, "--name", "greet"]);
let second = sb.ok(&["install"]);
assert!(
second.contains("cached") && !second.contains("fetched"),
"second install should be cached, got: {second}"
);
assert!(sb.read("ai.lock").contains(&head.to_lowercase()));
}
#[test]
fn container_path_warns_once_and_suggests_subskills() {
let sb = Sandbox::new();
sb.add_skill_pack();
sb.ok(&["init", "--target", "claude,copilot"]);
let out = sb.spm(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--path",
"pack",
"--name",
"pack",
]);
assert!(
out.status.success(),
"add should succeed with a warning: {}",
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
err.matches("has no SKILL.md at its root").count(),
1,
"warning must be emitted once, not per-vendor: {err}"
);
assert!(
err.contains("--branch main --path pack/alpha --name alpha"),
"should suggest a runnable alpha command with selector: {err}"
);
assert!(
err.contains("--branch main --path pack/beta --name beta"),
"should suggest a runnable beta command with selector: {err}"
);
}
#[test]
fn missing_skill_md_without_subskills_warns_once_generically() {
let sb = Sandbox::new();
sb.add_skill_pack();
sb.ok(&["init", "--target", "claude,copilot"]);
let out = sb.spm(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--path",
"bare",
"--name",
"bare",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
err.matches("has no SKILL.md at its root").count(),
1,
"generic warning must be emitted once: {err}"
);
assert!(err.contains("agents may ignore it"), "{err}");
assert!(
!err.contains("Did you mean"),
"must not offer sub-skill suggestions when there are none: {err}"
);
}
#[cfg(unix)]
#[test]
fn symlinked_skill_md_still_warns() {
let sb = Sandbox::new();
sb.add_symlinked_skill();
sb.ok(&["init", "--target", "copilot"]);
let out = sb.spm(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--path",
"linked",
"--name",
"linked",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("has no SKILL.md at its root"),
"symlinked SKILL.md must still warn: {err}"
);
assert!(!sb
.project
.join(".agents/skills/spm-managed-skills/linked/SKILL.md")
.exists());
}
#[test]
fn status_reports_materialized_skills() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude,copilot"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let out = sb.ok(&["status"]);
assert!(out.contains("greet"), "{out}");
assert!(out.contains("claude"), "{out}");
assert!(out.contains("copilot"), "{out}");
assert!(!out.contains("MISSING"), "nothing should be missing: {out}");
}
#[test]
fn status_flags_uninstalled_worktree() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
std::fs::remove_dir_all(sb.project.join(".agents")).unwrap();
let out = sb.spm(&["status"]);
assert!(
!out.status.success(),
"status must fail when declared skills are not materialized here"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("MISSING"),
"should mark greet MISSING: {text}"
);
assert!(
text.contains("spm install"),
"should tell the user to run `spm install` here: {text}"
);
}
#[test]
fn status_succeeds_when_no_skills_are_declared() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
let out = sb.ok(&["status"]);
assert!(out.contains("no skills declared"), "{out}");
assert!(
!out.contains("all declared skills are materialized"),
"must not claim materialization when nothing is declared: {out}"
);
}
#[test]
fn status_fails_when_declared_skills_are_not_locked() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
std::fs::remove_file(sb.project.join("ai.lock")).unwrap();
let out = sb.spm(&["status"]);
assert!(
!out.status.success(),
"status must fail when ai.json declares skills that ai.lock does not"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(text.contains("ai.lock has none"), "{text}");
assert!(text.contains("spm install"), "{text}");
}
#[test]
fn status_flags_claude_pointer_to_other_checkout() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let sp = sb.project.join(".claude/settings.local.json");
let other = sb
.root
.join("some-other-checkout")
.join(".spm")
.join("claude");
let mut settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sp).unwrap()).unwrap();
settings["extraKnownMarketplaces"]["spm"]["source"]["path"] =
serde_json::Value::String(other.to_string_lossy().into_owned());
std::fs::write(&sp, serde_json::to_string_pretty(&settings).unwrap()).unwrap();
let out = sb.spm(&["status"]);
assert!(
!out.status.success(),
"status must fail when the Claude marketplace points at another checkout"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("some-other-checkout"),
"should surface the mismatched registered path: {text}"
);
assert!(
text.contains("spm install"),
"should tell the user to run `spm install` here: {text}"
);
}