use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const EXIT_PENDING: i32 = 2;
fn agentlink() -> PathBuf {
let mut path = std::env::current_exe().expect("test binary path");
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.join(format!("agentlink{}", std::env::consts::EXE_SUFFIX))
}
struct Run {
stdout: String,
code: i32,
}
fn run(dir: &Path, args: &[&str]) -> Run {
let output = Command::new(agentlink())
.arg("--color")
.arg("never")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("agentlink should be built before integration tests run");
Run {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
code: output.status.code().unwrap_or(-1),
}
}
fn claude_only_repo() -> tempfile::TempDir {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path();
fs::create_dir_all(root.join(".claude/skills/code-review")).unwrap();
fs::write(
root.join("CLAUDE.md"),
"# My project\n\nUse pnpm, never npm.\n",
)
.unwrap();
fs::write(
root.join(".claude/skills/code-review/SKILL.md"),
"---\nname: code-review\ndescription: Reviews a diff\n---\n\nBe strict.\n",
)
.unwrap();
temp
}
#[test]
fn init_never_seeds_over_content_waiting_to_be_adopted() {
let repo = claude_only_repo();
let result = run(repo.path(), &["init"]);
assert_eq!(
result.code, EXIT_PENDING,
"init should report blocked work:\n{}",
result.stdout
);
assert!(
!repo.path().join("AGENTS.md").exists(),
"AGENTS.md must not be seeded yet"
);
assert!(
result.stdout.contains("agentlink adopt"),
"should name the fix:\n{}",
result.stdout
);
assert!(
fs::read_to_string(repo.path().join("CLAUDE.md"))
.unwrap()
.contains("Use pnpm")
);
}
#[test]
fn adopt_moves_content_canonically_and_serves_every_agent_in_one_pass() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
let result = run(root, &["adopt"]);
assert_eq!(result.code, 0, "adopt should succeed:\n{}", result.stdout);
assert!(
fs::read_to_string(root.join("AGENTS.md"))
.unwrap()
.contains("Use pnpm")
);
assert!(root.join(".agents/skills/code-review/SKILL.md").exists());
assert!(root.join(".claude/skills/code-review/SKILL.md").exists());
assert!(root.join("CLAUDE.md").exists());
for path in [".cursor/skills", ".github/skills", ".opencode/skills"] {
assert!(
root.join(path).join("code-review/SKILL.md").exists(),
"{path} should have been linked in the same run:\n{}",
result.stdout
);
}
assert!(result.stdout.contains("0 files copied"));
}
#[test]
fn apply_is_idempotent() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
run(root, &["adopt"]);
let second = run(root, &["apply"]);
assert_eq!(second.code, 0);
assert!(
second.stdout.contains("already up to date"),
"a second apply should change nothing:\n{}",
second.stdout
);
let third = run(root, &["status", "--check"]);
assert_eq!(
third.code, 0,
"status --check should be clean:\n{}",
third.stdout
);
}
#[test]
fn a_hand_written_file_is_never_replaced() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(root.join("AGENTS.md"), "# shared rules\n").unwrap();
fs::write(root.join("CLAUDE.md"), "# hand written, do not touch\n").unwrap();
run(root, &["init"]);
let result = run(root, &["apply"]);
assert_eq!(result.code, EXIT_PENDING);
assert_eq!(
fs::read_to_string(root.join("CLAUDE.md")).unwrap(),
"# hand written, do not touch\n",
"agentlink overwrote a file it did not create"
);
assert!(
result.stdout.contains("merge them by hand"),
"{}",
result.stdout
);
}
#[test]
fn status_check_signals_pending_work_for_ci() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
let pending = run(root, &["status", "--check"]);
assert_eq!(pending.code, EXIT_PENDING);
run(root, &["adopt"]);
let clean = run(root, &["status", "--check"]);
assert_eq!(clean.code, 0, "{}", clean.stdout);
}
#[test]
fn clean_removes_only_what_agentlink_created() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
run(root, &["adopt"]);
let result = run(root, &["clean"]);
assert_eq!(result.code, 0, "{}", result.stdout);
assert!(!root.join(".cursor/skills").exists());
assert!(!root.join("CLAUDE.md").exists());
assert!(
fs::read_to_string(root.join("AGENTS.md"))
.unwrap()
.contains("Use pnpm")
);
assert!(root.join(".agents/skills/code-review/SKILL.md").exists());
}
#[test]
fn dry_run_writes_nothing() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
let result = run(root, &["adopt", "--dry-run"]);
assert!(result.stdout.contains("dry run"), "{}", result.stdout);
assert!(!root.join("AGENTS.md").exists(), "dry run moved content");
assert!(root.join(".claude/skills/code-review/SKILL.md").exists());
}
#[test]
fn commands_refuse_to_run_before_init() {
let temp = tempfile::tempdir().unwrap();
let result = run(temp.path(), &["apply"]);
assert_eq!(result.code, 1);
}
#[test]
fn providers_lists_the_capability_matrix() {
let temp = tempfile::tempdir().unwrap();
let result = run(temp.path(), &["providers"]);
assert_eq!(result.code, 0);
for provider in [
"claude-code",
"antigravity",
"codex",
"cursor",
"github-copilot",
"opencode",
] {
assert!(
result.stdout.contains(provider),
"missing {provider}:\n{}",
result.stdout
);
}
assert!(result.stdout.contains("native"));
}
#[test]
fn doctor_reports_a_healthy_workspace() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
run(root, &["adopt"]);
let result = run(root, &["doctor"]);
assert_eq!(result.code, 0, "doctor found problems:\n{}", result.stdout);
assert!(
result.stdout.contains("no problems found"),
"{}",
result.stdout
);
}
#[test]
fn a_workspace_local_manifest_adds_an_agent_without_a_release() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
run(root, &["adopt"]);
fs::create_dir_all(root.join(".agentlink/providers")).unwrap();
fs::write(
root.join(".agentlink/providers/futureagent.toml"),
"schema = 1\n\
id = \"futureagent\"\n\
name = \"Future Agent\"\n\n\
[[capability]]\n\
resource = \"skills\"\n\
strategy = \"link\"\n\
path = \".future/skills\"\n",
)
.unwrap();
let result = run(root, &["apply"]);
assert_eq!(result.code, 0, "{}", result.stdout);
assert!(
root.join(".future/skills/code-review/SKILL.md").exists(),
"a local manifest should be served like any built-in:\n{}",
result.stdout
);
}
#[test]
fn an_invalid_local_manifest_fails_loudly_rather_than_being_ignored() {
let repo = claude_only_repo();
let root = repo.path();
run(root, &["init"]);
fs::create_dir_all(root.join(".agentlink/providers")).unwrap();
fs::write(
root.join(".agentlink/providers/broken.toml"),
"schema = 1\nid = \"broken\"\nname = \"Broken\"\n\n\
[[capability]]\nresource = \"skills\"\nstrategy = \"link\"\npathh = \".x/skills\"\n",
)
.unwrap();
let result = run(root, &["status"]);
assert_eq!(result.code, 1, "{}", result.stdout);
}
#[test]
fn the_gitignore_block_covers_every_materialised_path() {
let repo = claude_only_repo();
let root = repo.path();
fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
run(root, &["init"]);
run(root, &["adopt"]);
let gitignore = fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(
gitignore.starts_with("node_modules/"),
"existing entries must survive"
);
for path in [
"/CLAUDE.md",
"/.claude/skills",
"/.cursor/skills",
"/.github/skills",
] {
assert!(gitignore.contains(path), "missing {path} in:\n{gitignore}");
}
assert!(!gitignore.contains("/AGENTS.md"));
assert!(!gitignore.contains("/.agents/skills"));
assert!(gitignore.contains("/.agentlink/lock.toml"), "{gitignore}");
assert!(!gitignore.contains("/.agentlink/config.toml"));
}