use assert_cmd::Command;
use std::fs;
use std::path::Path;
const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
fn stage_registry(root: &Path) {
copy_dir_all(Path::new(&format!("{FIXTURES}/items")), root).unwrap();
copy_dir_all(
Path::new(&format!("{FIXTURES}/bundles")),
&root.join("bundles"),
)
.unwrap();
}
fn copy_dir_all(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let to_path = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &to_path)?;
} else {
fs::copy(entry.path(), &to_path)?;
}
}
Ok(())
}
#[test]
fn bundle_install_writes_only_referenced_items() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
let target = tmp.path().join("target");
stage_registry(®istry);
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(target.join(".git")).unwrap();
let bundle = registry.join("bundles/platform-baseline.bundle.yaml");
Command::cargo_bin("upskill")
.unwrap()
.current_dir(&target)
.args(["add", bundle.to_str().unwrap()])
.assert()
.success();
assert!(
target
.join(".claude/skills/create-api-endpoint/SKILL.md")
.exists(),
"skill installed"
);
assert!(
target
.join(".github/instructions/api-conventions.instructions.md")
.exists(),
"rule installed"
);
assert!(
target
.join(".opencode/agents/security-reviewer.md")
.exists(),
"agent installed"
);
}
#[test]
fn bundle_install_records_bundle_in_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
let target = tmp.path().join("target");
stage_registry(®istry);
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(target.join(".git")).unwrap();
let bundle = registry.join("bundles/platform-baseline.bundle.yaml");
Command::cargo_bin("upskill")
.unwrap()
.current_dir(&target)
.args(["add", bundle.to_str().unwrap()])
.assert()
.success();
let raw = fs::read_to_string(target.join(".upskill-lock.json")).unwrap();
let lock: serde_json::Value = serde_json::from_str(&raw).unwrap();
let bundles = lock["bundles"].as_array().expect("bundles array");
assert_eq!(bundles.len(), 1, "one bundle resolved");
assert_eq!(bundles[0]["name"], "platform-baseline");
let items = bundles[0]["items"].as_array().expect("bundle items array");
assert_eq!(items.len(), 4, "1 skill + 2 rules + 1 agent");
let names: Vec<&str> = lock["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"create-api-endpoint"));
assert!(names.contains(&"api-conventions"));
assert!(names.contains(&"license-awareness"));
assert!(names.contains(&"security-reviewer"));
}
#[test]
fn bundle_install_resolves_transitive_requires() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
let target = tmp.path().join("target");
stage_registry(®istry);
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(target.join(".git")).unwrap();
let extras_path = registry.join("bundles/platform-extras.bundle.yaml");
let extras = fs::read_to_string(&extras_path).unwrap();
let extras = extras.replace("\r\n", "\n");
fs::write(
&extras_path,
extras.replace(
"items:\n rules:\n - license-awareness",
"items:\n rules: []",
),
)
.unwrap();
Command::cargo_bin("upskill")
.unwrap()
.current_dir(&target)
.args(["add", extras_path.to_str().unwrap()])
.assert()
.success();
let raw = fs::read_to_string(target.join(".upskill-lock.json")).unwrap();
let lock: serde_json::Value = serde_json::from_str(&raw).unwrap();
let bundles = lock["bundles"].as_array().expect("bundles array");
let names: Vec<&str> = bundles
.iter()
.map(|b| b["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"platform-extras") && names.contains(&"platform-baseline"),
"transitive bundles recorded: {names:?}"
);
assert!(
target
.join(".github/instructions/api-conventions.instructions.md")
.exists(),
"baseline rule installed via transitive resolution"
);
}
#[test]
fn bundle_install_errors_on_item_conflict() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
let target = tmp.path().join("target");
stage_registry(®istry);
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(target.join(".git")).unwrap();
let extras = registry.join("bundles/platform-extras.bundle.yaml");
let assert = Command::cargo_bin("upskill")
.unwrap()
.current_dir(&target)
.args(["add", extras.to_str().unwrap()])
.assert()
.failure()
.code(1);
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("item conflict") && stderr.contains("license-awareness"),
"expected item-conflict error: {stderr}"
);
}
fn stage_sibling_layout_registry(root: &Path) {
let bundles_dir = root.join("bundles");
fs::create_dir_all(&bundles_dir).unwrap();
fs::write(
bundles_dir.join("sibling-test.bundle.yaml"),
"\
schema: 1
name: sibling-test
description: Test bundle with sibling layout
license: MIT
items:
rules:
- license-awareness
",
)
.unwrap();
let item_dir = root.join("skills/license-awareness");
fs::create_dir_all(&item_dir).unwrap();
fs::copy(
Path::new(&format!("{FIXTURES}/items/license-awareness/RULE.md")),
item_dir.join("RULE.md"),
)
.unwrap();
}
#[test]
fn bundle_install_sibling_layout_discovers_items_in_peer_directories() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("registry");
let target = tmp.path().join("target");
stage_sibling_layout_registry(®istry);
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(target.join(".git")).unwrap();
let bundle = registry.join("bundles/sibling-test.bundle.yaml");
Command::cargo_bin("upskill")
.unwrap()
.current_dir(&target)
.args(["add", bundle.to_str().unwrap()])
.assert()
.success();
assert!(
target
.join(".github/instructions/license-awareness.instructions.md")
.exists(),
"copilot rule installed from sibling layout"
);
assert!(
target
.join(".agents/rules/license-awareness/RULE.md")
.exists(),
"opencode rule installed from sibling layout"
);
let raw = fs::read_to_string(target.join(".upskill-lock.json")).unwrap();
let lock: serde_json::Value = serde_json::from_str(&raw).unwrap();
let items: Vec<&str> = lock["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["name"].as_str().unwrap())
.collect();
assert!(
items.contains(&"license-awareness"),
"lockfile records installed item: {items:?}"
);
}