mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-06 11:18:51 +03:00
feat(plugins): add plugin loading error handling and manifest validation
- Add structured error types for plugin loading failures - Add manifest field validation - Improve plugin API surface with consistent error patterns - 31 plugins tests pass, clippy clean
This commit is contained in:
@@ -648,6 +648,106 @@ pub struct PluginSummary {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PluginLoadFailure {
|
||||||
|
pub plugin_root: PathBuf,
|
||||||
|
pub kind: PluginKind,
|
||||||
|
pub source: String,
|
||||||
|
error: Box<PluginError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginLoadFailure {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(plugin_root: PathBuf, kind: PluginKind, source: String, error: PluginError) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin_root,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
error: Box::new(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn error(&self) -> &PluginError {
|
||||||
|
self.error.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for PluginLoadFailure {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"failed to load {} plugin from `{}` (source: {}): {}",
|
||||||
|
self.kind,
|
||||||
|
self.plugin_root.display(),
|
||||||
|
self.source,
|
||||||
|
self.error()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PluginRegistryReport {
|
||||||
|
registry: PluginRegistry,
|
||||||
|
failures: Vec<PluginLoadFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistryReport {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(registry: PluginRegistry, failures: Vec<PluginLoadFailure>) -> Self {
|
||||||
|
Self { registry, failures }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn registry(&self) -> &PluginRegistry {
|
||||||
|
&self.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn failures(&self) -> &[PluginLoadFailure] {
|
||||||
|
&self.failures
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_failures(&self) -> bool {
|
||||||
|
!self.failures.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summaries(&self) -> Vec<PluginSummary> {
|
||||||
|
self.registry.summaries()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_registry(self) -> Result<PluginRegistry, PluginError> {
|
||||||
|
if self.failures.is_empty() {
|
||||||
|
Ok(self.registry)
|
||||||
|
} else {
|
||||||
|
Err(PluginError::LoadFailures(self.failures))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct PluginDiscovery {
|
||||||
|
plugins: Vec<PluginDefinition>,
|
||||||
|
failures: Vec<PluginLoadFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginDiscovery {
|
||||||
|
fn push_plugin(&mut self, plugin: PluginDefinition) {
|
||||||
|
self.plugins.push(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_failure(&mut self, failure: PluginLoadFailure) {
|
||||||
|
self.failures.push(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend(&mut self, other: Self) {
|
||||||
|
self.plugins.extend(other.plugins);
|
||||||
|
self.failures.extend(other.failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
plugins: Vec<RegisteredPlugin>,
|
plugins: Vec<RegisteredPlugin>,
|
||||||
@@ -802,6 +902,10 @@ pub enum PluginManifestValidationError {
|
|||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
|
PathIsDirectory {
|
||||||
|
kind: &'static str,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
InvalidToolInputSchema {
|
InvalidToolInputSchema {
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
},
|
},
|
||||||
@@ -838,6 +942,9 @@ impl Display for PluginManifestValidationError {
|
|||||||
Self::MissingPath { kind, path } => {
|
Self::MissingPath { kind, path } => {
|
||||||
write!(f, "{kind} path `{}` does not exist", path.display())
|
write!(f, "{kind} path `{}` does not exist", path.display())
|
||||||
}
|
}
|
||||||
|
Self::PathIsDirectory { kind, path } => {
|
||||||
|
write!(f, "{kind} path `{}` must point to a file", path.display())
|
||||||
|
}
|
||||||
Self::InvalidToolInputSchema { tool_name } => {
|
Self::InvalidToolInputSchema { tool_name } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
@@ -860,6 +967,7 @@ pub enum PluginError {
|
|||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Json(serde_json::Error),
|
Json(serde_json::Error),
|
||||||
ManifestValidation(Vec<PluginManifestValidationError>),
|
ManifestValidation(Vec<PluginManifestValidationError>),
|
||||||
|
LoadFailures(Vec<PluginLoadFailure>),
|
||||||
InvalidManifest(String),
|
InvalidManifest(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
CommandFailed(String),
|
CommandFailed(String),
|
||||||
@@ -879,6 +987,15 @@ impl Display for PluginError {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Self::LoadFailures(failures) => {
|
||||||
|
for (index, failure) in failures.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
write!(f, "; ")?;
|
||||||
|
}
|
||||||
|
write!(f, "{failure}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Self::InvalidManifest(message)
|
Self::InvalidManifest(message)
|
||||||
| Self::NotFound(message)
|
| Self::NotFound(message)
|
||||||
| Self::CommandFailed(message) => write!(f, "{message}"),
|
| Self::CommandFailed(message) => write!(f, "{message}"),
|
||||||
@@ -935,15 +1052,23 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
Ok(PluginRegistry::new(
|
self.plugin_registry_report()?.into_registry()
|
||||||
self.discover_plugins()?
|
}
|
||||||
.into_iter()
|
|
||||||
.map(|plugin| {
|
pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
|
||||||
let enabled = self.is_enabled(plugin.metadata());
|
self.sync_bundled_plugins()?;
|
||||||
RegisteredPlugin::new(plugin, enabled)
|
|
||||||
})
|
let mut discovery = PluginDiscovery::default();
|
||||||
.collect(),
|
discovery.plugins.extend(builtin_plugins());
|
||||||
))
|
|
||||||
|
let installed = self.discover_installed_plugins_with_failures()?;
|
||||||
|
discovery.extend(installed);
|
||||||
|
|
||||||
|
let external =
|
||||||
|
self.discover_external_directory_plugins_with_failures(&discovery.plugins)?;
|
||||||
|
discovery.extend(external);
|
||||||
|
|
||||||
|
Ok(self.build_registry_report(discovery))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
||||||
@@ -955,11 +1080,12 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||||
self.sync_bundled_plugins()?;
|
Ok(self
|
||||||
let mut plugins = builtin_plugins();
|
.plugin_registry()?
|
||||||
plugins.extend(self.discover_installed_plugins()?);
|
.plugins
|
||||||
plugins.extend(self.discover_external_directory_plugins(&plugins)?);
|
.into_iter()
|
||||||
Ok(plugins)
|
.map(|plugin| plugin.definition)
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
||||||
@@ -1094,9 +1220,9 @@ impl PluginManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
fn discover_installed_plugins_with_failures(&self) -> Result<PluginDiscovery, PluginError> {
|
||||||
let mut registry = self.load_registry()?;
|
let mut registry = self.load_registry()?;
|
||||||
let mut plugins = Vec::new();
|
let mut discovery = PluginDiscovery::default();
|
||||||
let mut seen_ids = BTreeSet::<String>::new();
|
let mut seen_ids = BTreeSet::<String>::new();
|
||||||
let mut seen_paths = BTreeSet::<PathBuf>::new();
|
let mut seen_paths = BTreeSet::<PathBuf>::new();
|
||||||
let mut stale_registry_ids = Vec::new();
|
let mut stale_registry_ids = Vec::new();
|
||||||
@@ -1111,10 +1237,21 @@ impl PluginManager {
|
|||||||
|| install_path.display().to_string(),
|
|| install_path.display().to_string(),
|
||||||
|record| describe_install_source(&record.source),
|
|record| describe_install_source(&record.source),
|
||||||
);
|
);
|
||||||
let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
|
match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
|
||||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
Ok(plugin) => {
|
||||||
seen_paths.insert(install_path);
|
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||||
plugins.push(plugin);
|
seen_paths.insert(install_path);
|
||||||
|
discovery.push_plugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
discovery.push_failure(PluginLoadFailure::new(
|
||||||
|
install_path,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,15 +1264,27 @@ impl PluginManager {
|
|||||||
stale_registry_ids.push(record.id.clone());
|
stale_registry_ids.push(record.id.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let plugin = load_plugin_definition(
|
let source = describe_install_source(&record.source);
|
||||||
|
match load_plugin_definition(
|
||||||
&record.install_path,
|
&record.install_path,
|
||||||
record.kind,
|
record.kind,
|
||||||
describe_install_source(&record.source),
|
source.clone(),
|
||||||
record.kind.marketplace(),
|
record.kind.marketplace(),
|
||||||
)?;
|
) {
|
||||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
Ok(plugin) => {
|
||||||
seen_paths.insert(record.install_path.clone());
|
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||||
plugins.push(plugin);
|
seen_paths.insert(record.install_path.clone());
|
||||||
|
discovery.push_plugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
discovery.push_failure(PluginLoadFailure::new(
|
||||||
|
record.install_path.clone(),
|
||||||
|
record.kind,
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,47 +1295,51 @@ impl PluginManager {
|
|||||||
self.store_registry(®istry)?;
|
self.store_registry(®istry)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(plugins)
|
Ok(discovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_external_directory_plugins(
|
fn discover_external_directory_plugins_with_failures(
|
||||||
&self,
|
&self,
|
||||||
existing_plugins: &[PluginDefinition],
|
existing_plugins: &[PluginDefinition],
|
||||||
) -> Result<Vec<PluginDefinition>, PluginError> {
|
) -> Result<PluginDiscovery, PluginError> {
|
||||||
let mut plugins = Vec::new();
|
let mut discovery = PluginDiscovery::default();
|
||||||
|
|
||||||
for directory in &self.config.external_dirs {
|
for directory in &self.config.external_dirs {
|
||||||
for root in discover_plugin_dirs(directory)? {
|
for root in discover_plugin_dirs(directory)? {
|
||||||
let plugin = load_plugin_definition(
|
let source = root.display().to_string();
|
||||||
|
match load_plugin_definition(
|
||||||
&root,
|
&root,
|
||||||
PluginKind::External,
|
PluginKind::External,
|
||||||
root.display().to_string(),
|
source.clone(),
|
||||||
EXTERNAL_MARKETPLACE,
|
EXTERNAL_MARKETPLACE,
|
||||||
)?;
|
) {
|
||||||
if existing_plugins
|
Ok(plugin) => {
|
||||||
.iter()
|
if existing_plugins
|
||||||
.chain(plugins.iter())
|
.iter()
|
||||||
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
.chain(discovery.plugins.iter())
|
||||||
{
|
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
||||||
plugins.push(plugin);
|
{
|
||||||
|
discovery.push_plugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
discovery.push_failure(PluginLoadFailure::new(
|
||||||
|
root,
|
||||||
|
PluginKind::External,
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(plugins)
|
Ok(discovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
pub fn installed_plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
|
||||||
self.sync_bundled_plugins()?;
|
self.sync_bundled_plugins()?;
|
||||||
Ok(PluginRegistry::new(
|
Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
|
||||||
self.discover_installed_plugins()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|plugin| {
|
|
||||||
let enabled = self.is_enabled(plugin.metadata());
|
|
||||||
RegisteredPlugin::new(plugin, enabled)
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
|
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
|
||||||
@@ -1332,6 +1485,26 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
|
self.installed_plugin_registry_report()?.into_registry()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_registry_report(&self, discovery: PluginDiscovery) -> PluginRegistryReport {
|
||||||
|
PluginRegistryReport::new(
|
||||||
|
PluginRegistry::new(
|
||||||
|
discovery
|
||||||
|
.plugins
|
||||||
|
.into_iter()
|
||||||
|
.map(|plugin| {
|
||||||
|
let enabled = self.is_enabled(plugin.metadata());
|
||||||
|
RegisteredPlugin::new(plugin, enabled)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
discovery.failures,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -1676,6 +1849,8 @@ fn validate_command_entry(
|
|||||||
};
|
};
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
errors.push(PluginManifestValidationError::MissingPath { kind, path });
|
errors.push(PluginManifestValidationError::MissingPath { kind, path });
|
||||||
|
} else if !path.is_file() {
|
||||||
|
errors.push(PluginManifestValidationError::PathIsDirectory { kind, path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1783,6 +1958,12 @@ fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), Plu
|
|||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(PluginError::InvalidManifest(format!(
|
||||||
|
"{kind} path `{}` must point to a file",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2094,6 +2275,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_directory_path_plugin(root: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(root.join("hooks").join("pre-dir")).expect("hook dir");
|
||||||
|
fs::create_dir_all(root.join("tools").join("tool-dir")).expect("tool dir");
|
||||||
|
fs::create_dir_all(root.join("commands").join("sync-dir")).expect("command dir");
|
||||||
|
fs::create_dir_all(root.join("lifecycle").join("init-dir")).expect("lifecycle dir");
|
||||||
|
write_file(
|
||||||
|
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"directory path plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre-dir\"]\n }},\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init-dir\"]\n }},\n \"tools\": [\n {{\n \"name\": \"dir_tool\",\n \"description\": \"Directory tool\",\n \"inputSchema\": {{\"type\": \"object\"}},\n \"command\": \"./tools/tool-dir\"\n }}\n ],\n \"commands\": [\n {{\n \"name\": \"sync\",\n \"description\": \"Directory command\",\n \"command\": \"./commands/sync-dir\"\n }}\n ]\n}}"
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||||
let log_path = root.join("lifecycle.log");
|
let log_path = root.join("lifecycle.log");
|
||||||
write_file(
|
write_file(
|
||||||
@@ -2315,6 +2510,90 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_plugin_from_directory_rejects_missing_lifecycle_paths() {
|
||||||
|
// given
|
||||||
|
let root = temp_dir("manifest-lifecycle-paths");
|
||||||
|
write_file(
|
||||||
|
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||||
|
r#"{
|
||||||
|
"name": "missing-lifecycle-paths",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Missing lifecycle path validation",
|
||||||
|
"lifecycle": {
|
||||||
|
"Init": ["./lifecycle/init.sh"],
|
||||||
|
"Shutdown": ["./lifecycle/shutdown.sh"]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let error =
|
||||||
|
load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
|
||||||
|
|
||||||
|
// then
|
||||||
|
match error {
|
||||||
|
PluginError::ManifestValidation(errors) => {
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::MissingPath { kind, path }
|
||||||
|
if *kind == "lifecycle command"
|
||||||
|
&& path.ends_with(Path::new("lifecycle/init.sh"))
|
||||||
|
)));
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::MissingPath { kind, path }
|
||||||
|
if *kind == "lifecycle command"
|
||||||
|
&& path.ends_with(Path::new("lifecycle/shutdown.sh"))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
other => panic!("expected manifest validation errors, got {other}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_plugin_from_directory_rejects_directory_command_paths() {
|
||||||
|
// given
|
||||||
|
let root = temp_dir("manifest-directory-paths");
|
||||||
|
write_directory_path_plugin(&root, "directory-paths");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let error =
|
||||||
|
load_plugin_from_directory(&root).expect_err("directory command paths should fail");
|
||||||
|
|
||||||
|
// then
|
||||||
|
match error {
|
||||||
|
PluginError::ManifestValidation(errors) => {
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::PathIsDirectory { kind, path }
|
||||||
|
if *kind == "hook" && path.ends_with(Path::new("hooks/pre-dir"))
|
||||||
|
)));
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::PathIsDirectory { kind, path }
|
||||||
|
if *kind == "lifecycle command"
|
||||||
|
&& path.ends_with(Path::new("lifecycle/init-dir"))
|
||||||
|
)));
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::PathIsDirectory { kind, path }
|
||||||
|
if *kind == "tool" && path.ends_with(Path::new("tools/tool-dir"))
|
||||||
|
)));
|
||||||
|
assert!(errors.iter().any(|error| matches!(
|
||||||
|
error,
|
||||||
|
PluginManifestValidationError::PathIsDirectory { kind, path }
|
||||||
|
if *kind == "command" && path.ends_with(Path::new("commands/sync-dir"))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
other => panic!("expected manifest validation errors, got {other}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_rejects_invalid_permissions() {
|
fn load_plugin_from_directory_rejects_invalid_permissions() {
|
||||||
let root = temp_dir("manifest-invalid-permissions");
|
let root = temp_dir("manifest-invalid-permissions");
|
||||||
@@ -2806,6 +3085,80 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
|
||||||
|
// given
|
||||||
|
let config_home = temp_dir("report-home");
|
||||||
|
let external_root = temp_dir("report-external");
|
||||||
|
write_external_plugin(&external_root.join("valid"), "valid-report", "1.0.0");
|
||||||
|
write_broken_plugin(&external_root.join("broken"), "broken-report");
|
||||||
|
|
||||||
|
let mut config = PluginManagerConfig::new(&config_home);
|
||||||
|
config.external_dirs = vec![external_root.clone()];
|
||||||
|
let manager = PluginManager::new(config);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let report = manager
|
||||||
|
.plugin_registry_report()
|
||||||
|
.expect("report should tolerate invalid external plugins");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(report.registry().contains("valid-report@external"));
|
||||||
|
assert_eq!(report.failures().len(), 1);
|
||||||
|
assert_eq!(report.failures()[0].kind, PluginKind::External);
|
||||||
|
assert!(report.failures()[0]
|
||||||
|
.plugin_root
|
||||||
|
.ends_with(Path::new("broken")));
|
||||||
|
assert!(report.failures()[0]
|
||||||
|
.error()
|
||||||
|
.to_string()
|
||||||
|
.contains("does not exist"));
|
||||||
|
|
||||||
|
let error = manager
|
||||||
|
.plugin_registry()
|
||||||
|
.expect_err("strict registry should surface load failures");
|
||||||
|
match error {
|
||||||
|
PluginError::LoadFailures(failures) => {
|
||||||
|
assert_eq!(failures.len(), 1);
|
||||||
|
assert!(failures[0].plugin_root.ends_with(Path::new("broken")));
|
||||||
|
}
|
||||||
|
other => panic!("expected load failures, got {other}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(external_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
|
||||||
|
// given
|
||||||
|
let config_home = temp_dir("installed-report-home");
|
||||||
|
let bundled_root = temp_dir("installed-report-bundled");
|
||||||
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
|
write_external_plugin(&install_root.join("valid"), "installed-valid", "1.0.0");
|
||||||
|
write_broken_plugin(&install_root.join("broken"), "installed-broken");
|
||||||
|
|
||||||
|
let mut config = PluginManagerConfig::new(&config_home);
|
||||||
|
config.bundled_root = Some(bundled_root.clone());
|
||||||
|
config.install_root = Some(install_root);
|
||||||
|
let manager = PluginManager::new(config);
|
||||||
|
|
||||||
|
// when
|
||||||
|
let report = manager
|
||||||
|
.installed_plugin_registry_report()
|
||||||
|
.expect("installed report should tolerate invalid installed plugins");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(report.registry().contains("installed-valid@external"));
|
||||||
|
assert_eq!(report.failures().len(), 1);
|
||||||
|
assert!(report.failures()[0]
|
||||||
|
.plugin_root
|
||||||
|
.ends_with(Path::new("broken")));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||||
let config_home = temp_dir("broken-home");
|
let config_home = temp_dir("broken-home");
|
||||||
|
|||||||
Reference in New Issue
Block a user