#![allow(clippy::unwrap_used)]
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
fn bb(project: &std::path::Path, cfg: &std::path::Path) -> Command {
let mut cmd = Command::cargo_bin("bb").unwrap();
cmd.current_dir(project)
.env("HOME", cfg)
.env("XDG_CONFIG_HOME", cfg)
.env("NO_COLOR", "1")
.env("BB_KEYRING_DISABLE", "1");
cmd
}
#[test]
fn install_creates_the_agents_skill_and_says_so() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success()
.stdout(contains(".agents/skills/bitbucket-cloud/SKILL.md"));
let installed = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
assert!(installed.is_file(), "skill was not written");
let text = std::fs::read_to_string(installed).unwrap();
assert!(text.starts_with("---"), "installed file is not the skill");
}
#[test]
fn install_needs_no_credentials() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.env_remove("BB_EMAIL")
.env_remove("BB_TOKEN")
.assert()
.success();
}
#[test]
fn install_is_idempotent() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success()
.stdout(contains("unchanged"));
}
#[test]
fn a_modified_skill_makes_install_exit_one_without_clobbering() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
let path = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
std::fs::write(&path, "# ours\n").unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.code(1)
.stdout(contains("skipped_modified").not())
.stderr(contains("skipped_modified"))
.stderr(contains("--force"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "# ours\n");
bb(project.path(), cfg.path())
.args(["skill", "install", "--force"])
.assert()
.success();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("---"));
}
#[test]
fn status_reports_current_then_modified() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
bb(project.path(), cfg.path())
.args(["skill", "status"])
.assert()
.success()
.stdout(contains("current"));
std::fs::write(
project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md"),
"# ours\n",
)
.unwrap();
bb(project.path(), cfg.path())
.args(["skill", "status"])
.assert()
.success()
.stdout(contains("modified").and(contains("skipped_modified").not()));
}
#[test]
fn status_reports_missing_when_the_file_is_deleted() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
std::fs::remove_file(
project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md"),
)
.unwrap();
bb(project.path(), cfg.path())
.args(["skill", "status"])
.assert()
.success()
.stdout(contains("missing"));
}
#[test]
fn uninstall_removes_the_file_and_forgets_it() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success();
assert!(!project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md")
.exists());
let out = bb(project.path(), cfg.path())
.args(["skill", "status", "--json"])
.output()
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value, serde_json::json!([]));
}
#[test]
fn uninstall_leaves_a_modified_file_alone_without_force() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install"])
.assert()
.success();
let path = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
std::fs::write(&path, "# ours\n").unwrap();
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success();
assert!(
path.exists(),
"a customized skill must not be deleted silently"
);
}
#[test]
fn json_output_is_pure_on_every_subcommand() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
for args in [
vec!["skill", "status", "--json"],
vec!["skill", "install", "--json"],
vec!["skill", "status", "--json"],
vec!["skill", "uninstall", "--json"],
] {
let out = bb(project.path(), cfg.path()).args(&args).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<serde_json::Value>(stdout.trim())
.unwrap_or_else(|e| panic!("{args:?} stdout was not JSON: {e}\n{stdout}"));
}
}
#[test]
fn a_corrupt_state_file_does_not_break_the_command() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
let state = cfg.path().join("bb/skills.json");
std::fs::create_dir_all(state.parent().unwrap()).unwrap();
std::fs::write(&state, "{not json").unwrap();
bb(project.path(), cfg.path())
.args(["skill", "status"])
.assert()
.success();
}
#[test]
fn a_corrupt_state_file_warns_on_uninstall_too() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
let state = cfg.path().join("bb/skills.json");
std::fs::create_dir_all(state.parent().unwrap()).unwrap();
std::fs::write(&state, "{not json").unwrap();
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success()
.stderr(contains("skills.json"));
}
#[test]
fn global_install_and_uninstall_target_home_not_the_project() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install", "--global"])
.assert()
.success();
let global_path = cfg.path().join(".agents/skills/bitbucket-cloud/SKILL.md");
let project_path = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
assert!(
global_path.is_file(),
"global install should write under HOME"
);
assert!(
!project_path.exists(),
"global install must not touch the project directory"
);
bb(project.path(), cfg.path())
.args(["skill", "uninstall", "--global"])
.assert()
.success();
assert!(
!global_path.exists(),
"global uninstall should remove the HOME copy"
);
}
#[test]
fn install_agent_claude_over_a_hand_made_symlink_then_uninstall_preserves_agents_copy() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join(".agents/skills/bitbucket-cloud")).unwrap();
std::fs::write(
project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md"),
std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/.agents/skills/bitbucket-cloud/SKILL.md"
))
.unwrap(),
)
.unwrap();
let claude_dir = project.path().join(".claude/skills/bitbucket-cloud");
std::fs::create_dir_all(claude_dir.parent().unwrap()).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../../.agents/skills/bitbucket-cloud", &claude_dir).unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install", "--agent", "claude"])
.assert()
.success();
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success();
let agents_file = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
assert!(
agents_file.is_file(),
"the .agents copy must survive uninstalling the claude link"
);
assert!(
std::fs::symlink_metadata(&claude_dir).is_err(),
"no dangling claude symlink should remain — Path::exists() would wrongly \
report false for a dangling link, so this checks symlink_metadata instead"
);
}
#[test]
fn uninstall_messages_distinguish_removed_refused_and_absent() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install", "--agent", "all"])
.assert()
.success();
let agents_path = project
.path()
.join(".agents/skills/bitbucket-cloud/SKILL.md");
std::fs::write(&agents_path, "# ours\n").unwrap();
let claude_dir = project.path().join(".claude/skills/bitbucket-cloud");
if claude_dir.exists() || std::fs::symlink_metadata(&claude_dir).is_ok() {
let _ = std::fs::remove_dir_all(&claude_dir);
let _ = std::fs::remove_file(&claude_dir);
}
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success()
.stderr(contains(
"edited locally — left alone (pass --force to remove)",
))
.stdout(contains("already gone — nothing to remove"));
}
#[test]
fn uninstall_removes_a_symlinked_claude_entry() {
let project = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
bb(project.path(), cfg.path())
.args(["skill", "install", "--agent", "all"])
.assert()
.success();
let claude_dir = project.path().join(".claude/skills/bitbucket-cloud");
assert!(claude_dir.join("SKILL.md").exists() || claude_dir.exists());
bb(project.path(), cfg.path())
.args(["skill", "uninstall"])
.assert()
.success();
assert!(
!claude_dir.exists() && !claude_dir.join("SKILL.md").exists(),
"the claude entry (link or file) should be gone"
);
}