mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 23:54:50 +08: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,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct PluginRegistry {
|
||||
plugins: Vec<RegisteredPlugin>,
|
||||
@@ -802,6 +902,10 @@ pub enum PluginManifestValidationError {
|
||||
kind: &'static str,
|
||||
path: PathBuf,
|
||||
},
|
||||
PathIsDirectory {
|
||||
kind: &'static str,
|
||||
path: PathBuf,
|
||||
},
|
||||
InvalidToolInputSchema {
|
||||
tool_name: String,
|
||||
},
|
||||
@@ -838,6 +942,9 @@ impl Display for PluginManifestValidationError {
|
||||
Self::MissingPath { kind, path } => {
|
||||
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 } => {
|
||||
write!(
|
||||
f,
|
||||
@@ -860,6 +967,7 @@ pub enum PluginError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
ManifestValidation(Vec<PluginManifestValidationError>),
|
||||
LoadFailures(Vec<PluginLoadFailure>),
|
||||
InvalidManifest(String),
|
||||
NotFound(String),
|
||||
CommandFailed(String),
|
||||
@@ -879,6 +987,15 @@ impl Display for PluginError {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::LoadFailures(failures) => {
|
||||
for (index, failure) in failures.iter().enumerate() {
|
||||
if index > 0 {
|
||||
write!(f, "; ")?;
|
||||
}
|
||||
write!(f, "{failure}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::InvalidManifest(message)
|
||||
| Self::NotFound(message)
|
||||
| Self::CommandFailed(message) => write!(f, "{message}"),
|
||||
@@ -935,15 +1052,23 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||
Ok(PluginRegistry::new(
|
||||
self.discover_plugins()?
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
let enabled = self.is_enabled(plugin.metadata());
|
||||
RegisteredPlugin::new(plugin, enabled)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
self.plugin_registry_report()?.into_registry()
|
||||
}
|
||||
|
||||
pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
|
||||
self.sync_bundled_plugins()?;
|
||||
|
||||
let mut discovery = PluginDiscovery::default();
|
||||
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> {
|
||||
@@ -955,11 +1080,12 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||
self.sync_bundled_plugins()?;
|
||||
let mut plugins = builtin_plugins();
|
||||
plugins.extend(self.discover_installed_plugins()?);
|
||||
plugins.extend(self.discover_external_directory_plugins(&plugins)?);
|
||||
Ok(plugins)
|
||||
Ok(self
|
||||
.plugin_registry()?
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.definition)
|
||||
.collect())
|
||||
}
|
||||
|
||||
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 plugins = Vec::new();
|
||||
let mut discovery = PluginDiscovery::default();
|
||||
let mut seen_ids = BTreeSet::<String>::new();
|
||||
let mut seen_paths = BTreeSet::<PathBuf>::new();
|
||||
let mut stale_registry_ids = Vec::new();
|
||||
@@ -1111,10 +1237,21 @@ impl PluginManager {
|
||||
|| install_path.display().to_string(),
|
||||
|record| describe_install_source(&record.source),
|
||||
);
|
||||
let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
|
||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||
seen_paths.insert(install_path);
|
||||
plugins.push(plugin);
|
||||
match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
|
||||
Ok(plugin) => {
|
||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||
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());
|
||||
continue;
|
||||
}
|
||||
let plugin = load_plugin_definition(
|
||||
let source = describe_install_source(&record.source);
|
||||
match load_plugin_definition(
|
||||
&record.install_path,
|
||||
record.kind,
|
||||
describe_install_source(&record.source),
|
||||
source.clone(),
|
||||
record.kind.marketplace(),
|
||||
)?;
|
||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||
seen_paths.insert(record.install_path.clone());
|
||||
plugins.push(plugin);
|
||||
) {
|
||||
Ok(plugin) => {
|
||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||
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)?;
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
Ok(discovery)
|
||||
}
|
||||
|
||||
fn discover_external_directory_plugins(
|
||||
fn discover_external_directory_plugins_with_failures(
|
||||
&self,
|
||||
existing_plugins: &[PluginDefinition],
|
||||
) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||
let mut plugins = Vec::new();
|
||||
) -> Result<PluginDiscovery, PluginError> {
|
||||
let mut discovery = PluginDiscovery::default();
|
||||
|
||||
for directory in &self.config.external_dirs {
|
||||
for root in discover_plugin_dirs(directory)? {
|
||||
let plugin = load_plugin_definition(
|
||||
let source = root.display().to_string();
|
||||
match load_plugin_definition(
|
||||
&root,
|
||||
PluginKind::External,
|
||||
root.display().to_string(),
|
||||
source.clone(),
|
||||
EXTERNAL_MARKETPLACE,
|
||||
)?;
|
||||
if existing_plugins
|
||||
.iter()
|
||||
.chain(plugins.iter())
|
||||
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
||||
{
|
||||
plugins.push(plugin);
|
||||
) {
|
||||
Ok(plugin) => {
|
||||
if existing_plugins
|
||||
.iter()
|
||||
.chain(discovery.plugins.iter())
|
||||
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
||||
{
|
||||
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()?;
|
||||
Ok(PluginRegistry::new(
|
||||
self.discover_installed_plugins()?
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
let enabled = self.is_enabled(plugin.metadata());
|
||||
RegisteredPlugin::new(plugin, enabled)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -1676,6 +1849,8 @@ fn validate_command_entry(
|
||||
};
|
||||
if !path.exists() {
|
||||
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()
|
||||
)));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(PluginError::InvalidManifest(format!(
|
||||
"{kind} path `{}` must point to a file",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
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 {
|
||||
let log_path = root.join("lifecycle.log");
|
||||
write_file(
|
||||
@@ -2315,6 +2510,90 @@ mod tests {
|
||||
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]
|
||||
fn load_plugin_from_directory_rejects_invalid_permissions() {
|
||||
let root = temp_dir("manifest-invalid-permissions");
|
||||
@@ -2806,6 +3085,80 @@ mod tests {
|
||||
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]
|
||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||
let config_home = temp_dir("broken-home");
|
||||
|
||||
Reference in New Issue
Block a user