use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::{inspect_source, install, remove, set_enabled, InstallMode};
use crate::plugins::{
discover_with_trust, state::PluginScope, PluginOrigin, PluginStatus, ProjectTrust,
};
const SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
pub(super) struct Env {
pub(super) home: TempDir,
pub(super) project: TempDir,
pub(super) rho_home: TempDir,
}
pub(super) fn env() -> Env {
let home = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let rho_home = TempDir::new().unwrap();
std::fs::create_dir(project.path().join(".git")).unwrap();
Env {
home,
project,
rho_home,
}
}
pub(super) fn write_source(root: &Path, name: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("skills").join("hello")).unwrap();
std::fs::write(
dir.join("plugin.json"),
format!(
r#"{{"$schema":"{SCHEMA}","name":"{name}","version":"1.2.3","description":"demo plugin"}}"#
),
)
.unwrap();
std::fs::write(
dir.join("skills/hello/SKILL.md"),
"---\nname: hello\ndescription: greets\n---\nbody\n",
)
.unwrap();
dir
}
#[test]
fn install_copies_into_user_root_after_validation() {
let env = env();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "demo");
let package = install(
&source,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
assert_eq!(package.name, "demo");
assert_eq!(package.origin, PluginOrigin::Install);
assert_eq!(package.version.as_deref(), Some("1.2.3"));
let installed = env.home.path().join(".agents/plugins/demo/plugin.json");
assert!(installed.is_file());
assert!(source.join("plugin.json").is_file());
let discovery = discover_with_trust(
env.project.path(),
Some(env.home.path()),
Some(env.rho_home.path()),
ProjectTrust::Trusted,
);
let entry = discovery.report.find("demo").unwrap();
assert_eq!(entry.status, PluginStatus::Loaded);
assert_eq!(entry.origin, PluginOrigin::Install);
assert_eq!(entry.skill_names, ["hello"]);
}
#[test]
fn relative_source_paths_resolve_against_cwd() {
let env = env();
write_source(&env.project.path().join("pkg"), "copied");
write_source(&env.project.path().join("pkg"), "linked");
let cases = [
("pkg/copied", InstallMode::Copy, PluginOrigin::Install),
("pkg/linked", InstallMode::Link, PluginOrigin::Link),
];
for (relative, mode, origin) in cases {
let package = install(
Path::new(relative),
PluginScope::User,
mode,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
assert_eq!(package.origin, origin, "{relative}");
assert!(package.path.join("plugin.json").is_file(), "{relative}");
}
}
#[test]
fn install_rejects_invalid_package_before_copy() {
let env = env();
let source_root = TempDir::new().unwrap();
let bad = source_root.path().join("bad");
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(bad.join("plugin.json"), r#"{"name":"bad"}"#).unwrap();
let error = install(
&bad,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap_err();
assert!(error.to_string().contains("invalid plugin package"));
assert!(!env.home.path().join(".agents/plugins/bad").exists());
}
#[test]
fn install_conflict_requires_force() {
let env = env();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "demo");
install(
&source,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
let error = install(
&source,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap_err();
assert!(error.to_string().contains("--force"));
install(
&source,
PluginScope::User,
InstallMode::Copy,
true,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
}
#[test]
fn project_install_from_home_is_refused() {
let cases = [
("no repository", false, "", false),
("dotfiles repository", true, "notes", false),
#[cfg(unix)]
("symlinked home", false, "", true),
];
for (label, repository, relative_cwd, symlinked) in cases {
let physical = TempDir::new().unwrap();
let rho_home = TempDir::new().unwrap();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "demo");
if repository {
std::fs::create_dir(physical.path().join(".git")).unwrap();
}
let cwd = physical.path().join(relative_cwd);
std::fs::create_dir_all(&cwd).unwrap();
let alias = TempDir::new().unwrap();
let home = if symlinked {
let link = alias.path().join("home");
#[cfg(unix)]
std::os::unix::fs::symlink(physical.path(), &link).unwrap();
link
} else {
physical.path().to_path_buf()
};
let install_as = |scope| {
install(
&source,
scope,
InstallMode::Copy,
false,
&cwd,
&home,
Some(rho_home.path()),
)
};
let project = install_as(PluginScope::Project);
let untouched =
!physical.path().join(".agents").exists() && !physical.path().join(".rho").exists();
let user = install_as(PluginScope::User);
assert_eq!(
(project.is_err(), untouched, user.is_ok()),
(true, true, true),
"{label}"
);
}
}
#[test]
fn dotfiles_nested_project_plugin_disable_applies() {
let home = TempDir::new().unwrap();
let rho_home = TempDir::new().unwrap();
std::fs::create_dir(home.path().join(".git")).unwrap();
let cwd = home.path().join("notes");
write_source(&cwd.join(".agents/plugins"), "nested");
let status = || {
let discovery = discover_with_trust(
&cwd,
Some(home.path()),
Some(rho_home.path()),
ProjectTrust::Trusted,
);
let entry = discovery.report.find("nested").unwrap();
(entry.scope, entry.status)
};
set_enabled(
"nested",
false,
&cwd,
Some(home.path()),
Some(rho_home.path()),
)
.unwrap();
assert_eq!(status(), (PluginScope::Project, PluginStatus::Disabled));
}
#[test]
fn home_project_state_from_older_builds() {
let cases = [
(
"disable kept",
"version = 1\n[plugins.demo]\nenabled = false\n",
PluginStatus::Disabled,
),
("unreadable ignored", "not toml [", PluginStatus::Loaded),
];
for (label, legacy, before) in cases {
let home = TempDir::new().unwrap();
let rho_home = TempDir::new().unwrap();
write_source(&home.path().join(".agents/plugins"), "demo");
let legacy_path = home.path().join(".rho/plugins.toml");
std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
std::fs::write(&legacy_path, legacy).unwrap();
let status = || {
let discovery = discover_with_trust(
home.path(),
Some(home.path()),
Some(rho_home.path()),
ProjectTrust::Untrusted,
);
let entry = discovery.report.find("demo").unwrap();
(entry.scope, entry.status)
};
let initial = status();
set_enabled(
"demo",
true,
home.path(),
Some(home.path()),
Some(rho_home.path()),
)
.unwrap();
assert_eq!(
(initial, status()),
(
(PluginScope::User, before),
(PluginScope::User, PluginStatus::Loaded)
),
"{label}"
);
}
}
#[cfg(unix)]
#[test]
fn link_creates_managed_symlink() {
let env = env();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "linked");
let package = install(
&source,
PluginScope::Project,
InstallMode::Link,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
let dest = env.project.path().join(".agents/plugins/linked");
assert_eq!(package.origin, PluginOrigin::Link);
assert!(std::fs::symlink_metadata(&dest)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
std::fs::canonicalize(&dest).unwrap(),
std::fs::canonicalize(&source).unwrap()
);
}
#[test]
fn disable_keeps_package_and_drops_session_components() {
let env = env();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "demo");
install(
&source,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
set_enabled(
"demo",
false,
env.project.path(),
Some(env.home.path()),
Some(env.rho_home.path()),
)
.unwrap();
assert!(env
.home
.path()
.join(".agents/plugins/demo/plugin.json")
.is_file());
let discovery = discover_with_trust(
env.project.path(),
Some(env.home.path()),
Some(env.rho_home.path()),
ProjectTrust::Trusted,
);
assert!(discovery.skills.is_empty());
assert!(!discovery.mcp.has_enabled_servers());
let entry = discovery.report.find("demo").unwrap();
assert_eq!(entry.status, PluginStatus::Disabled);
assert!(!entry.enabled);
assert_eq!(entry.skill_count, 1);
}
#[test]
fn remove_deletes_package_but_keeps_data_directory() {
let env = env();
let source_root = TempDir::new().unwrap();
let source = write_source(source_root.path(), "demo");
install(
&source,
PluginScope::User,
InstallMode::Copy,
false,
env.project.path(),
env.home.path(),
Some(env.rho_home.path()),
)
.unwrap();
let plugins_root = env.home.path().join(".agents/plugins");
let data = plugins_root.join("data/demo");
std::fs::create_dir_all(&data).unwrap();
std::fs::write(data.join("state.json"), "{}").unwrap();
remove(
"demo",
env.project.path(),
Some(env.home.path()),
Some(env.rho_home.path()),
)
.unwrap();
assert!(!plugins_root.join("demo").exists());
assert!(data.join("state.json").is_file());
}
#[test]
fn inspect_source_reads_manifest_only() {
let root = TempDir::new().unwrap();
let source = write_source(root.path(), "meta");
let inspected = inspect_source(&source).unwrap();
assert_eq!(inspected.manifest.name, "meta");
assert_eq!(inspected.manifest.version.as_deref(), Some("1.2.3"));
assert_eq!(
inspected.manifest.description.as_deref(),
Some("demo plugin")
);
}