use std::{fs, path::Path};
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::{tempdir, TempDir};
struct Fixture {
tmp: TempDir,
}
impl Fixture {
fn new() -> Self {
Self {
tmp: tempdir().unwrap(),
}
}
fn root(&self) -> &Path {
self.tmp.path()
}
fn path(&self, rel: &str) -> std::path::PathBuf {
self.root().join(rel)
}
fn write_config(&self, body: impl AsRef<str>) -> std::path::PathBuf {
let path = self.path("skillnet.toml");
fs::write(&path, body.as_ref()).unwrap();
path
}
fn write_catalog_config(&self, body: impl AsRef<str>) -> std::path::PathBuf {
let path = self.path("skillnet.catalog.toml");
fs::write(&path, body.as_ref()).unwrap();
path
}
fn command(&self, config: &Path) -> Command {
let mut command = Command::cargo_bin("skillnet").unwrap();
command.args([
"--config",
config.to_str().unwrap(),
"--mirror-root",
self.root().to_str().unwrap(),
]);
command
}
fn command_with_catalog(&self, config: &Path, catalog_config: &Path) -> Command {
let mut command = self.command(config);
command.args(["--catalog-config", catalog_config.to_str().unwrap()]);
command
}
}
fn write_skill(root: &Path, name: &str, body: &str) {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), body).unwrap();
}
fn minimal_config() -> &'static str {
"[global]\nsources = []\nsync_paths = []\nstale_codex_skill_paths = []\n"
}
fn global_config(agents: &Path, claude: &Path, codex: &Path) -> String {
format!(
r#"
[global]
sources = [
{{ label = "agents", path = "{}", priority = 3 }},
{{ label = "claude", path = "{}", priority = 2 }},
{{ label = "codex", path = "{}", priority = 1 }},
]
sync_paths = ["{}", "{}"]
stale_codex_skill_paths = ["{}"]
"#,
agents.display(),
claude.display(),
codex.display(),
agents.display(),
claude.display(),
codex.display()
)
}
#[test]
fn sync_pull_only_writes_the_selected_mirror_scope() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
write_skill(&agents, "alpha", "a");
fs::create_dir_all(&claude).unwrap();
fs::create_dir_all(&codex).unwrap();
let config = fixture.write_config(global_config(&agents, &claude, &codex));
fixture
.command(&config)
.args(["sync", "pull", "--scope", "global"])
.assert()
.success();
assert!(fixture.path("global/alpha/SKILL.md").is_file());
assert!(codex.exists());
assert!(fixture.path(".skillnet/cache.toml").is_file());
}
#[test]
fn sync_pull_then_push_writes_live_targets_and_removes_stale_codex_skills() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
write_skill(&codex, "alpha", "a");
fs::create_dir_all(&agents).unwrap();
fs::create_dir_all(&claude).unwrap();
let config = fixture.write_config(global_config(&agents, &claude, &codex));
fixture
.command(&config)
.args(["sync", "pull", "--scope", "global", "--then-push"])
.assert()
.success();
assert!(agents.join("alpha/SKILL.md").is_file());
assert!(claude.join("alpha/SKILL.md").is_file());
assert!(!codex.exists());
}
#[test]
fn sync_pull_then_push_does_not_push_after_a_pull_failure() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
let bad_config = fixture.write_config("not = [valid");
fixture
.command(&bad_config)
.args(["sync", "pull", "--scope", "global", "--then-push"])
.assert()
.failure()
.stderr(predicate::str::contains("failed to parse config file"));
assert!(!agents.exists());
assert!(!claude.exists());
assert!(!codex.exists());
}
#[test]
fn project_scopes_are_pulled_with_all_and_empty_projects_get_manifests() {
let fixture = Fixture::new();
let project_a = fixture.path("repos/project-a");
let project_b = fixture.path("repos/project-b");
write_skill(&project_a.join(".agents/skills"), "alpha", "a");
fs::create_dir_all(&project_b).unwrap();
let config = fixture.write_config(format!(
r#"
[global]
sources = []
sync_paths = []
stale_codex_skill_paths = []
[[project_source_rules]]
label = "agents"
rel = ".agents/skills"
priority = 1
[[projects]]
name = "project-a"
path = "{}"
[[projects]]
name = "project-b"
path = "{}"
"#,
project_a.display(),
project_b.display()
));
fixture
.command(&config)
.args(["sync", "pull", "--all"])
.assert()
.success();
assert!(fixture.path("projects/project-a/alpha/SKILL.md").is_file());
assert!(fixture
.path("projects/project-b/RECONCILIATION.md")
.is_file());
}
#[test]
fn skill_delete_honors_global_dry_run() {
let fixture = Fixture::new();
write_skill(&fixture.path("global"), "alpha", "a");
let config = fixture.write_config(minimal_config());
fixture
.command(&config)
.args(["--dry-run", "skill", "delete", "global/alpha"])
.assert()
.success()
.stdout(predicate::str::contains("delete"));
assert!(fixture.path("global/alpha/SKILL.md").is_file());
}
#[test]
fn skill_rename_and_move_update_mirrored_skill_directories() {
let fixture = Fixture::new();
let project = fixture.path("repos/demo");
fs::create_dir_all(&project).unwrap();
write_skill(&fixture.path("global"), "alpha", "a");
let config = fixture.write_config(format!(
r#"
[global]
sources = []
sync_paths = []
stale_codex_skill_paths = []
[[projects]]
name = "demo"
path = "{}"
"#,
project.display()
));
fixture
.command(&config)
.args(["skill", "rename", "global/alpha", "beta"])
.assert()
.success();
assert!(!fixture.path("global/alpha").exists());
assert!(fixture.path("global/beta/SKILL.md").is_file());
fixture
.command(&config)
.args(["skill", "move", "global/beta", "demo"])
.assert()
.success();
assert!(!fixture.path("global/beta").exists());
assert!(fixture.path("projects/demo/beta/SKILL.md").is_file());
fixture
.command(&config)
.args(["skill", "move", "demo/beta", "global"])
.assert()
.success();
assert!(fixture.path("global/beta/SKILL.md").is_file());
assert!(!fixture.path("projects/demo/beta").exists());
}
#[test]
fn skill_list_scope_list_and_scope_sources_report_configured_state() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
write_skill(&fixture.path("global"), "alpha", "a");
fs::create_dir_all(&agents).unwrap();
fs::create_dir_all(&claude).unwrap();
fs::create_dir_all(&codex).unwrap();
let config = fixture.write_config(global_config(&agents, &claude, &codex));
fixture
.command(&config)
.args(["skill", "list", "--scope", "global"])
.assert()
.success()
.stdout(predicate::str::contains("# global"))
.stdout(predicate::str::contains("alpha"));
fixture
.command(&config)
.args(["scope", "list"])
.assert()
.success()
.stdout(predicate::str::contains("global"));
fixture
.command(&config)
.args(["scope", "sources", "--scope", "global"])
.assert()
.success()
.stdout(predicate::str::contains("agents"))
.stdout(predicate::str::contains("claude"))
.stdout(predicate::str::contains("codex"));
}
#[test]
fn invalid_scope_reports_valid_configured_scopes() {
let fixture = Fixture::new();
let project = fixture.path("repos/demo");
fs::create_dir_all(&project).unwrap();
let config = fixture.write_config(format!(
r#"
[global]
sources = []
sync_paths = []
stale_codex_skill_paths = []
[[projects]]
name = "demo"
path = "{}"
"#,
project.display()
));
fixture
.command(&config)
.args(["sync", "status", "--scope", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown scope `nope`"))
.stderr(predicate::str::contains("global, demo"));
}
#[test]
fn project_add_remove_and_list_update_config() {
let fixture = Fixture::new();
let project_root = fixture.path("repos/new-project");
fs::create_dir_all(&project_root).unwrap();
let config = fixture.write_config(minimal_config());
fixture
.command(&config)
.args([
"project",
"add",
"new-project",
project_root.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("added project new-project"));
let updated = fs::read_to_string(&config).unwrap();
assert!(updated.contains("[[projects]]"));
assert!(updated.contains("name = \"new-project\""));
assert!(updated.contains(&format!("path = \"{}\"", project_root.display())));
fixture
.command(&config)
.args(["project", "list"])
.assert()
.success()
.stdout(predicate::str::contains("new-project"));
fixture
.command(&config)
.args(["project", "remove", "new-project"])
.assert()
.success()
.stdout(predicate::str::contains("removed project new-project"));
let updated = fs::read_to_string(&config).unwrap();
assert!(!updated.contains("new-project"));
}
#[test]
fn project_add_dry_run_does_not_mutate_config() {
let fixture = Fixture::new();
let original = minimal_config();
let config = fixture.write_config(original);
fixture
.command(&config)
.args([
"--dry-run",
"project",
"add",
"future-project",
"/tmp/future-project",
"--allow-missing",
])
.assert()
.success()
.stdout(predicate::str::contains("add project future-project"));
assert_eq!(fs::read_to_string(&config).unwrap(), original);
}
#[test]
fn catalog_generate_creates_docs_and_project_indexes() {
let fixture = Fixture::new();
let config = fixture.write_config(minimal_config());
let catalog_config = fixture.write_catalog_config(
r#"
[[rules]]
path_prefix = "global/"
scope = "global"
category = "agent-tools"
status = "active"
[[rules]]
path_prefix = "projects/"
scope = "project"
category = "domain-workflow"
status = "active"
"#,
);
write_skill(
&fixture.path("global"),
"alpha",
"---\nname: alpha\ndescription: Alpha skill\n---\n",
);
write_skill(
&fixture.path("projects/demo"),
"beta",
"---\nname: beta\ndescription: Beta skill\n---\n",
);
fixture
.command_with_catalog(&config, &catalog_config)
.args(["catalog", "generate"])
.assert()
.success();
assert!(fixture.path("CATALOG.md").is_file());
assert!(fixture.path("ROUTING.md").is_file());
assert!(fixture.path("SKILL_CONFLICTS.md").is_file());
assert!(fixture.path("projects/demo/INDEX.md").is_file());
}
#[test]
fn catalog_lint_rejects_invalid_metadata() {
let fixture = Fixture::new();
let config = fixture.write_config(minimal_config());
let catalog_config = fixture.write_catalog_config(
r#"
[[rules]]
path_prefix = "global/"
scope = "global"
category = "wrong"
status = "active"
related_skills = ["missing-skill"]
"#,
);
write_skill(
&fixture.path("global"),
"alpha",
"---\nname: alpha\ndescription: Alpha skill\n---\n",
);
fixture
.command_with_catalog(&config, &catalog_config)
.args(["catalog", "lint"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown category"))
.stderr(predicate::str::contains("related skill"));
}
#[test]
fn skill_show_and_catalog_search_use_effective_metadata() {
let fixture = Fixture::new();
let config = fixture.write_config(minimal_config());
let catalog_config = fixture.write_catalog_config(
r#"
[[rules]]
path_prefix = "global/"
scope = "global"
category = "ci-release"
status = "active"
tags = ["forgejo"]
"#,
);
write_skill(
&fixture.path("global"),
"forgejo-ci",
"---\nname: forgejo-ci\ndescription: Forgejo CI skill\n---\n",
);
fixture
.command_with_catalog(&config, &catalog_config)
.args(["skill", "show", "global/forgejo-ci"])
.assert()
.success()
.stdout(predicate::str::contains("path:"))
.stdout(predicate::str::contains("catalog entry:"))
.stdout(predicate::str::contains("category: ci-release"));
fixture
.command_with_catalog(&config, &catalog_config)
.args(["catalog", "search", "forgejo"])
.assert()
.success()
.stdout(predicate::str::contains("global/forgejo-ci"));
}
#[test]
fn skill_show_missing_skill_reports_not_found() {
let fixture = Fixture::new();
let config = fixture.write_config(minimal_config());
fixture
.command(&config)
.args(["skill", "show", "global/missing"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn no_args_runs_status() {
let fixture = Fixture::new();
let config = fixture.write_config(minimal_config());
fixture
.command(&config)
.assert()
.success()
.stdout(predicate::str::contains("scopes:"))
.stdout(predicate::str::contains("global"));
}
#[test]
fn cache_is_best_effort_for_status() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
write_skill(&agents, "alpha", "a");
fs::create_dir_all(&claude).unwrap();
fs::create_dir_all(&codex).unwrap();
let config = fixture.write_config(global_config(&agents, &claude, &codex));
let cache = fixture.path(".skillnet/cache.toml");
fixture
.command(&config)
.args(["sync", "pull", "--scope", "global"])
.assert()
.success();
assert!(cache.is_file());
fs::remove_file(&cache).unwrap();
fixture
.command(&config)
.args(["status"])
.assert()
.success()
.stdout(predicate::str::contains("global"));
fs::create_dir_all(cache.parent().unwrap()).unwrap();
fs::write(&cache, "garbage").unwrap();
fixture
.command(&config)
.args(["status"])
.assert()
.success()
.stdout(predicate::str::contains("global"));
}
#[test]
fn sync_status_reports_clean_and_then_diverged() {
let fixture = Fixture::new();
let agents = fixture.path("home/.agents/skills");
let claude = fixture.path("home/.claude/skills");
let codex = fixture.path("home/.codex/skills");
write_skill(&agents, "alpha", "a");
fs::create_dir_all(&claude).unwrap();
fs::create_dir_all(&codex).unwrap();
let config = fixture.write_config(global_config(&agents, &claude, &codex));
fixture
.command(&config)
.args(["sync", "pull", "--scope", "global"])
.assert()
.success();
fixture
.command(&config)
.args(["sync", "status", "--scope", "global"])
.assert()
.success()
.stdout(predicate::str::contains("global clean"));
fs::write(agents.join("alpha/SKILL.md"), "changed").unwrap();
fixture
.command(&config)
.args(["sync", "status", "--scope", "global"])
.assert()
.success()
.stdout(predicate::str::contains("global diverged"));
}
#[test]
fn global_dry_run_sync_push_plans_all_configured_scopes() {
let fixture = Fixture::new();
let project_root = fixture.path("repos/demo");
fs::create_dir_all(&project_root).unwrap();
let config = fixture.write_config(format!(
r#"
[global]
sources = []
sync_paths = []
stale_codex_skill_paths = []
[[projects]]
name = "demo"
path = "{}"
"#,
project_root.display()
));
fixture
.command(&config)
.args(["--dry-run", "sync", "push", "--all"])
.assert()
.success()
.stdout(predicate::str::contains("# sync global"))
.stdout(predicate::str::contains("# sync demo"));
}