From f52b8f68de7c861acb70cce48a4134d79b7aa12c Mon Sep 17 00:00:00 2001 From: Martin Obe Date: Mon, 1 Jun 2026 17:07:19 +0100 Subject: [PATCH 1/2] Closes #256: Add plugin command --- src/commands/completions.rs | 55 +++++++++++++++++- src/commands/plugin.rs | 109 ++++++++++++++++++++++++++++++++---- src/main.rs | 13 +++++ src/plugins/interface.rs | 18 ++++++ src/plugins/loader.rs | 8 +++ src/plugins/registry.rs | 32 ++++++++--- 6 files changed, 212 insertions(+), 23 deletions(-) diff --git a/src/commands/completions.rs b/src/commands/completions.rs index d7bd46ee..ca024f5c 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; -use std::io; +use std::io::{self, Write}; /// Shell to generate completions for #[derive(Subcommand)] @@ -15,16 +15,65 @@ pub enum CompletionShell { } pub fn handle(shell: CompletionShell) -> Result<()> { - let mut cmd = Cli::command(); let shell = match shell { CompletionShell::Bash => Shell::Bash, CompletionShell::Zsh => Shell::Zsh, CompletionShell::Fish => Shell::Fish, }; - generate(shell, &mut cmd, "starforge", &mut io::stdout()); + let mut buf = Vec::new(); + generate_completion(shell, &mut buf); + + // Append plugin command completions so they are visible in tab completion. + let plugin_cmds = crate::plugins::registry::load_all_registered_commands(); + if !plugin_cmds.is_empty() { + append_plugin_completions(shell, &plugin_cmds, &mut buf); + } + + io::stdout().write_all(&buf)?; Ok(()) } +fn append_plugin_completions( + shell: Shell, + cmds: &[crate::plugins::registry::RegisteredCommand], + buf: &mut Vec, +) { + use std::io::Write; + match shell { + Shell::Fish => { + for cmd in cmds { + let _ = writeln!( + buf, + "complete -c starforge -n '__fish_use_subcommand starforge' -f -a '{}' -d '{}'", + cmd.name, + cmd.description.replace('\'', "\\'") + ); + } + } + Shell::Bash => { + // Inject plugin names into the top-level subcommand list. + let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect(); + let _ = writeln!( + buf, + "\n# Plugin commands\n_starforge_plugin_cmds='{}'\n", + names.join(" ") + ); + } + Shell::Zsh => { + let _ = writeln!(buf, "\n# Plugin commands"); + for cmd in cmds { + let _ = writeln!( + buf, + "# plugin: {} -- {}", + cmd.name, + cmd.description.replace('\'', "\\'") + ); + } + } + _ => {} + } +} + /// Generate completion script to a writer instead of stdout (used in tests). pub fn generate_completion(shell: Shell, writer: &mut impl io::Write) { let mut cmd = Cli::command(); diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index e015d848..a550d640 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,10 +1,9 @@ use crate::plugins::interface::CORE_VERSION; use crate::plugins::manifest; -use crate::plugins::registry::{self, TrustLevel, UninstallOptions}; +use crate::plugins::registry::{self, RegisteredCommand, TrustLevel, UninstallOptions}; use crate::plugins::PluginManager; use crate::utils::print as p; use anyhow::{Context, Result}; -use chrono; use clap::Subcommand; use std::path::PathBuf; @@ -63,6 +62,11 @@ pub enum PluginCommands { #[arg(long, default_value = "false")] yes: bool, }, + /// List commands registered by installed plugins + Commands { + /// Show commands for a specific plugin only + name: Option, + }, } pub fn handle(cmd: PluginCommands) -> Result<()> { @@ -78,6 +82,7 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { PluginCommands::Uninstall { name, purge, yes } => uninstall(name, purge, yes), PluginCommands::Verify { name } => verify(name), PluginCommands::Update { name, yes } => update(name, yes), + PluginCommands::Commands { name } => commands(name), } } @@ -105,12 +110,29 @@ fn install(name: String, path: Option, source: Option, force: b let plugin_manifest = manifest::require_compatible_manifest(&lib_path, &name)?; + // Load the plugin to discover the commands it registers. + let discovered_commands: Vec = { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(&lib_path) + .with_context(|| format!("Failed to load plugin '{}' to discover commands", name))?; + } + pm.list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect() + }; + registry::install_plugin( &name, &lib_path, source_str, &plugin_manifest.starforge_version, &plugin_manifest.version, + discovered_commands.clone(), )?; p::header("Plugin Install"); @@ -123,6 +145,12 @@ fn install(name: String, path: Option, source: Option, force: b if !source_str.is_empty() { p::kv("Source", source_str); } + if !discovered_commands.is_empty() { + p::info("Registered commands:"); + for cmd in &discovered_commands { + p::info(&format!(" • {} — {}", cmd.name, cmd.description)); + } + } p::info("Load plugins with: starforge plugin load"); Ok(()) } @@ -316,9 +344,6 @@ fn update(name: Option, yes: bool) -> Result<()> { pl.name )); p::kv(" Path", &pl.path); - if let Some(ref ts) = pl.installed_at { - p::kv(" Installed at", ts); - } skipped += 1; println!(); continue; @@ -356,7 +381,10 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { - registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source)?; + // Re-discover commands from the updated library. + let cmds = discover_commands_from_library(&pl.path) + .unwrap_or_else(|_| pl.commands.clone()); + registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version, cmds)?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; } @@ -388,19 +416,19 @@ fn update(name: Option, yes: bool) -> Result<()> { }) .unwrap_or(0); - let installed_epoch = pl - .installed_at - .as_deref() - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.timestamp() as u64) - .unwrap_or(0); + let installed_epoch = 0u64; // no install timestamp stored; treat as always-stale if modified > installed_epoch { // Library on disk is newer — refresh the registry entry. + let cmds = discover_commands_from_library(&pl.path) + .unwrap_or_else(|_| pl.commands.clone()); registry::install_plugin( &pl.name, std::path::Path::new(&pl.path), &pl.source, + &pl.starforge_version, + &pl.plugin_version, + cmds, )?; p::success(&format!( " '{}' library on disk is newer — registry refreshed.", @@ -518,3 +546,60 @@ fn verify(name: Option) -> Result<()> { Ok(()) } + +fn discover_commands_from_library(path: &str) -> Result> { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(path) + .with_context(|| format!("Failed to load plugin from {}", path))?; + } + Ok(pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect()) +} + +fn commands(name: Option) -> Result<()> { + p::header("Plugin Commands"); + + let reg = registry::load_registry().unwrap_or_default(); + if reg.plugins.is_empty() { + p::info("No plugins installed. Use: starforge plugin install --path "); + return Ok(()); + } + + let plugins: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); + if found.is_empty() { + anyhow::bail!("Plugin '{}' is not installed. Run `starforge plugin list`.", n); + } + found + } + None => reg.plugins.iter().collect(), + }; + + let mut any = false; + for pl in &plugins { + if pl.commands.is_empty() { + continue; + } + any = true; + p::kv_accent("Plugin", &pl.name); + for cmd in &pl.commands { + println!(" starforge {} — {}", cmd.name, cmd.description); + } + println!(); + } + + if !any { + p::info("No commands registered. Re-install plugins to discover their commands."); + p::info(" starforge plugin install --path "); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 490c5680..38af2380 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,6 +206,19 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { ); } + // Check if the command matches any registered plugin command before loading .so files. + let all_commands = plugins::registry::load_all_registered_commands(); + let known = all_commands.iter().any(|c| c.name == *plugin_name); + if !known { + let available: Vec = all_commands.iter().map(|c| format!(" • {}", c.name)).collect(); + let hint = if available.is_empty() { + "No plugin commands registered. Re-install plugins to discover their commands.".to_string() + } else { + format!("Available plugin commands:\n{}", available.join("\n")) + }; + anyhow::bail!("Unknown command '{}'.\n\n{}", plugin_name, hint); + } + // Warn about unknown-trust plugins before loading. for pl in reg .plugins diff --git a/src/plugins/interface.rs b/src/plugins/interface.rs index d1cb4090..8be8e352 100644 --- a/src/plugins/interface.rs +++ b/src/plugins/interface.rs @@ -1,10 +1,28 @@ use std::any::Any; +/// A command (or subcommand) that a plugin exposes to the StarForge CLI. +#[derive(Debug, Clone)] +pub struct PluginCommand { + /// The command name users type, e.g. `"defi"` or `"defi swap"`. + pub name: String, + /// One-line description shown in help and completions. + pub description: String, +} + pub trait Plugin: Any + Send + Sync { fn name(&self) -> &'static str; fn version(&self) -> &'static str; fn description(&self) -> &'static str; + /// Commands this plugin registers. Defaults to a single top-level command + /// named after the plugin itself so existing plugins need no changes. + fn commands(&self) -> Vec { + vec![PluginCommand { + name: self.name().to_string(), + description: self.description().to_string(), + }] + } + fn on_load(&self) {} fn on_unload(&self) {} diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 85015659..0cce500f 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -98,6 +98,14 @@ impl PluginManager { .collect() } + /// Returns all `PluginCommand`s advertised by every loaded plugin. + pub fn list_commands(&self) -> Vec { + self.plugins + .values() + .flat_map(|(p, _)| p.commands()) + .collect() + } + pub fn execute(&self, name: &str, args: &[String]) -> Result<(), String> { if let Some((plugin, _)) = self.plugins.get(name) { plugin.execute(args) diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index b9c2079f..bdecc70d 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -75,6 +75,16 @@ pub struct InstalledPlugin { /// Plugin version from manifest. #[serde(default)] pub plugin_version: String, + /// Commands registered by this plugin (name → description). + #[serde(default)] + pub commands: Vec, +} + +/// A command entry persisted in the registry so it is visible without loading the .so. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisteredCommand { + pub name: String, + pub description: String, } fn registry_path() -> Result { @@ -146,27 +156,22 @@ pub fn is_managed_plugin_path(path: &Path) -> bool { /// /// `source` is the URL or identifier where the plugin came from; pass an /// empty string when the user supplied `--path` directly. +/// `commands` is the list of commands the plugin advertises (from `Plugin::commands()`). pub fn install_plugin( name: &str, library_path: &Path, source: &str, starforge_version: &str, plugin_version: &str, + commands: Vec, ) -> Result<()> { if !library_path.exists() { anyhow::bail!("Plugin library not found: {}", library_path.display()); } let trust = classify_source(source); - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut reg = load_registry().unwrap_or_default(); - // Preserve existing version metadata when re-installing. - let existing_version = reg - .plugins - .iter() - .find(|p| p.name == name) - .and_then(|p| p.version.clone()); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { name: name.to_string(), @@ -175,12 +180,23 @@ pub fn install_plugin( trust, starforge_version: starforge_version.to_string(), plugin_version: plugin_version.to_string(), + commands, }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; Ok(()) } +/// Return all commands registered across all installed plugins (read from registry, no .so load). +pub fn load_all_registered_commands() -> Vec { + load_registry() + .unwrap_or_default() + .plugins + .into_iter() + .flat_map(|p| p.commands) + .collect() +} + /// Remove a plugin from the registry and optionally delete its library file. pub fn uninstall_plugin(name: &str, opts: &UninstallOptions) -> Result { let mut reg = load_registry().unwrap_or_default(); @@ -356,7 +372,7 @@ mod tests { fn install_missing_library_fails() { let tmp = TempDir::new().unwrap(); let missing = tmp.path().join("nonexistent.so"); - let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0"); + let result = install_plugin("test", &missing, "", "0.1.0", "1.0.0", vec![]); assert!(result.is_err(), "installing a missing library must fail"); assert!(result.unwrap_err().to_string().contains("not found")); } From b841992138a6abee997d85825dee4cc0251e22a5 Mon Sep 17 00:00:00 2001 From: Martin Obe Date: Mon, 1 Jun 2026 19:53:51 +0100 Subject: [PATCH 2/2] Closes #259: Add plugin loading failure --- src/commands/plugin.rs | 50 ++++- src/plugins/loader.rs | 304 +++++++++++++++++++++++++--- src/plugins/mod.rs | 2 +- tests/plugin_loading_diagnostics.rs | 72 +++++++ 4 files changed, 392 insertions(+), 36 deletions(-) create mode 100644 tests/plugin_loading_diagnostics.rs diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index a550d640..5d9b6cb3 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,7 +1,7 @@ use crate::plugins::interface::CORE_VERSION; use crate::plugins::manifest; use crate::plugins::registry::{self, RegisteredCommand, TrustLevel, UninstallOptions}; -use crate::plugins::PluginManager; +use crate::plugins::{PluginLoadError, PluginManager}; use crate::utils::print as p; use anyhow::{Context, Result}; use clap::Subcommand; @@ -205,26 +205,54 @@ fn load() -> Result<()> { } let mut pm = PluginManager::new(); + let mut failed: Vec<(String, PluginLoadError)> = Vec::new(); + for pl in ®.plugins { - unsafe { - pm.load_plugin(&pl.path) - .with_context(|| format!("Failed to load plugin '{}' from {}", pl.name, pl.path))?; + match unsafe { pm.load_plugin_diagnosed(&pl.path) } { + Ok(()) => {} + Err(e) => failed.push((pl.name.clone(), e)), + } + } + + // ── Report failures with structured diagnostics ────────────────────────── + if !failed.is_empty() { + p::warn(&format!( + "{} plugin(s) failed to load:", + failed.len() + )); + for (name, err) in &failed { + println!(); + p::error(&format!("[{}] {}", err.category(), name)); + for line in err.diagnostic().lines() { + println!(" {}", line); + } } + println!(); } let loaded = pm.list_plugins(); - if loaded.is_empty() { + if loaded.is_empty() && failed.is_empty() { p::warn("No plugins loaded."); return Ok(()); } - p::kv("StarForge core version", CORE_VERSION); - p::separator(); - for (name, desc, built_for) in loaded { - p::kv_accent(name, desc); - p::kv("Built for StarForge", built_for); + if !loaded.is_empty() { + p::kv("StarForge core version", CORE_VERSION); + p::separator(); + for (name, desc, built_for) in loaded { + p::kv_accent(name, desc); + p::kv("Built for StarForge", built_for); + } + p::separator(); } - p::separator(); + + if !failed.is_empty() { + anyhow::bail!( + "{} plugin(s) failed to load. See diagnostics above.", + failed.len() + ); + } + Ok(()) } diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 0cce500f..7b10c126 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -1,14 +1,111 @@ use crate::plugins::interface::{ - is_core_version_compatible, Plugin, PluginDeclaration, PluginRegistrar, RUSTC_VERSION, + is_core_version_compatible, Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION, + RUSTC_VERSION, }; use std::path::Path; use crate::plugins::manifest; -use anyhow::{Context, Result}; +use anyhow::Result; use libloading::{Library, Symbol}; use std::collections::HashMap; use std::ffi::OsStr; use std::rc::Rc; +/// Structured diagnostic for a plugin loading failure. +/// +/// Each variant maps to a distinct root cause so callers can surface +/// actionable guidance rather than a raw error string. +#[derive(Debug)] +pub enum PluginLoadError { + /// The file could not be opened as a shared library (wrong format, missing + /// file, OS-level load error, etc.). + InvalidLibrary { + path: String, + detail: String, + }, + /// The `PLUGIN_DECLARATION` symbol was absent — the binary is not a + /// StarForge plugin or was stripped. + MissingRequiredSymbol { + path: String, + symbol: String, + }, + /// The plugin was compiled with a different `rustc` version, making the + /// Rust ABI incompatible. + AbiBuildMismatch { + path: String, + plugin_rustc: String, + required_rustc: String, + }, + /// The plugin targets a different StarForge major version. + UnsupportedCoreVersion { + path: String, + plugin_core: String, + running_core: String, + }, + /// The `starforge-plugin.toml` manifest failed validation. + ManifestIncompatible { + path: String, + detail: String, + }, +} + +impl PluginLoadError { + /// A short label identifying the failure category. + pub fn category(&self) -> &'static str { + match self { + Self::InvalidLibrary { .. } => "invalid_library", + Self::MissingRequiredSymbol { .. } => "missing_symbol", + Self::AbiBuildMismatch { .. } => "abi_mismatch", + Self::UnsupportedCoreVersion { .. } => "unsupported_core_version", + Self::ManifestIncompatible { .. } => "manifest_incompatible", + } + } + + /// Human-readable explanation with a suggested fix. + pub fn diagnostic(&self) -> String { + match self { + Self::InvalidLibrary { path, detail } => format!( + "Cannot load shared library '{path}'.\n \ + Cause: {detail}\n \ + Fix: Verify the file is a valid .so/.dylib/.dll built for this platform.", + ), + Self::MissingRequiredSymbol { path, symbol } => format!( + "Required symbol '{symbol}' not found in '{path}'.\n \ + Cause: The binary is not a StarForge plugin or was built without `export_plugin!`.\n \ + Fix: Ensure the plugin crate calls `starforge_plugin_sdk::export_plugin!(register_fn)` \ + and is compiled as a `cdylib`.", + ), + Self::AbiBuildMismatch { path, plugin_rustc, required_rustc } => format!( + "ABI mismatch in '{path}'.\n \ + Plugin rustc : {plugin_rustc}\n \ + Required rustc: {required_rustc}\n \ + Fix: Rebuild the plugin with the same Rust toolchain used to build StarForge \ + (`rustup override set `).", + ), + Self::UnsupportedCoreVersion { path, plugin_core, running_core } => format!( + "Unsupported StarForge core version in '{path}'.\n \ + Plugin targets : StarForge {plugin_core}\n \ + Running : StarForge {running_core}\n \ + Fix: Rebuild the plugin for StarForge {running_core}, or add a \ + 'starforge-plugin.toml' with `starforge_version = \"{running_core}\"` \ + and rebuild.", + ), + Self::ManifestIncompatible { path, detail } => format!( + "Plugin manifest incompatible for '{path}'.\n \ + Detail: {detail}\n \ + Fix: Update 'starforge-plugin.toml' to match the running StarForge version.", + ), + } + } +} + +impl std::fmt::Display for PluginLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.diagnostic()) + } +} + +impl std::error::Error for PluginLoadError {} + pub struct PluginManager { /// Maps plugin name → (plugin, core_version it was built against). plugins: HashMap, String)>, @@ -33,44 +130,63 @@ impl PluginManager { /// The caller must ensure the plugin at `path` is a valid StarForge plugin /// compiled with a compatible Rust toolchain and ABI. pub unsafe fn load_plugin>(&mut self, path: P) -> Result<()> { + self.load_plugin_diagnosed(path) + .map_err(|e| anyhow::anyhow!("{}", e)) + } + + /// Like [`load_plugin`] but returns a structured [`PluginLoadError`] on + /// failure so callers can display category-specific diagnostics. + /// + /// # Safety + /// Same contract as [`load_plugin`]. + pub unsafe fn load_plugin_diagnosed>( + &mut self, + path: P, + ) -> std::result::Result<(), PluginLoadError> { let path_ref = path.as_ref(); let path_display = path_ref.to_string_lossy().to_string(); - let library = Rc::new(Library::new(path_ref).context("Failed to load library")?); - let decl: Symbol<*mut PluginDeclaration> = library - .get(b"PLUGIN_DECLARATION") - .context("Failed to find PLUGIN_DECLARATION symbol — is this a StarForge plugin?")?; + // ── Open the shared library ────────────────────────────────────────── + let library = Library::new(path_ref).map_err(|e| PluginLoadError::InvalidLibrary { + path: path_display.clone(), + detail: e.to_string(), + })?; + let library = Rc::new(library); + + // ── Locate the required export symbol ──────────────────────────────── + let decl: Symbol<*mut PluginDeclaration> = + library + .get(b"PLUGIN_DECLARATION") + .map_err(|_| PluginLoadError::MissingRequiredSymbol { + path: path_display.clone(), + symbol: "PLUGIN_DECLARATION".to_string(), + })?; let decl = &**decl; // ── rustc ABI check ────────────────────────────────────────────────── if decl.rustc_version != RUSTC_VERSION { - anyhow::bail!( - "Plugin ABI mismatch in '{path_display}':\n \ - Plugin was compiled with rustc {plugin_rustc}\n \ - StarForge requires rustc {core_rustc}\n\n \ - Rebuild the plugin with the same Rust toolchain used to build StarForge.", - path_display = path_display, - plugin_rustc = decl.rustc_version, - core_rustc = RUSTC_VERSION, - ); + return Err(PluginLoadError::AbiBuildMismatch { + path: path_display, + plugin_rustc: decl.rustc_version.to_string(), + required_rustc: RUSTC_VERSION.to_string(), + }); } // ── StarForge core version check ───────────────────────────────────── if !is_core_version_compatible(decl.core_version) { - anyhow::bail!(manifest::format_binary_incompatibility( - decl.core_version, - &path_display - )); + return Err(PluginLoadError::UnsupportedCoreVersion { + path: path_display, + plugin_core: decl.core_version.to_string(), + running_core: CORE_VERSION.to_string(), + }); } // ── Manifest compatibility (if present beside the library) ─────────── if let Ok(Some(mf)) = manifest::load_manifest_for_library(Path::new(path_ref)) { - mf.validate().with_context(|| { - format!( - "Plugin manifest compatibility check failed for '{}'", - path_display - ) + mf.validate().map_err(|e| PluginLoadError::ManifestIncompatible { + path: path_display.clone(), + detail: e.to_string(), })?; } @@ -132,3 +248,143 @@ impl PluginRegistrar for ProxyRegistrar { self.plugins.push(plugin); } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── Category labels ────────────────────────────────────────────────────── + + #[test] + fn invalid_library_category() { + let e = PluginLoadError::InvalidLibrary { + path: "/tmp/bad.so".into(), + detail: "No such file".into(), + }; + assert_eq!(e.category(), "invalid_library"); + } + + #[test] + fn missing_symbol_category() { + let e = PluginLoadError::MissingRequiredSymbol { + path: "/tmp/plugin.so".into(), + symbol: "PLUGIN_DECLARATION".into(), + }; + assert_eq!(e.category(), "missing_symbol"); + } + + #[test] + fn abi_mismatch_category() { + let e = PluginLoadError::AbiBuildMismatch { + path: "/tmp/plugin.so".into(), + plugin_rustc: "rustc 1.70.0".into(), + required_rustc: "rustc 1.80.0".into(), + }; + assert_eq!(e.category(), "abi_mismatch"); + } + + #[test] + fn unsupported_core_version_category() { + let e = PluginLoadError::UnsupportedCoreVersion { + path: "/tmp/plugin.so".into(), + plugin_core: "0.1.0".into(), + running_core: "1.0.0".into(), + }; + assert_eq!(e.category(), "unsupported_core_version"); + } + + #[test] + fn manifest_incompatible_category() { + let e = PluginLoadError::ManifestIncompatible { + path: "/tmp/plugin.so".into(), + detail: "major version mismatch".into(), + }; + assert_eq!(e.category(), "manifest_incompatible"); + } + + // ── Diagnostic messages contain actionable guidance ────────────────────── + + #[test] + fn invalid_library_diagnostic_mentions_fix() { + let e = PluginLoadError::InvalidLibrary { + path: "/tmp/bad.so".into(), + detail: "invalid ELF header".into(), + }; + let msg = e.diagnostic(); + assert!(msg.contains("/tmp/bad.so")); + assert!(msg.contains("invalid ELF header")); + assert!(msg.contains(".so") || msg.contains(".dylib") || msg.contains(".dll")); + } + + #[test] + fn missing_symbol_diagnostic_mentions_export_macro() { + let e = PluginLoadError::MissingRequiredSymbol { + path: "/tmp/plugin.so".into(), + symbol: "PLUGIN_DECLARATION".into(), + }; + let msg = e.diagnostic(); + assert!(msg.contains("PLUGIN_DECLARATION")); + assert!(msg.contains("export_plugin")); + assert!(msg.contains("cdylib")); + } + + #[test] + fn abi_mismatch_diagnostic_shows_both_versions() { + let e = PluginLoadError::AbiBuildMismatch { + path: "/tmp/plugin.so".into(), + plugin_rustc: "rustc 1.70.0".into(), + required_rustc: "rustc 1.80.0".into(), + }; + let msg = e.diagnostic(); + assert!(msg.contains("rustc 1.70.0")); + assert!(msg.contains("rustc 1.80.0")); + assert!(msg.contains("rustup")); + } + + #[test] + fn unsupported_core_version_diagnostic_shows_both_versions() { + let e = PluginLoadError::UnsupportedCoreVersion { + path: "/tmp/plugin.so".into(), + plugin_core: "0.1.0".into(), + running_core: "1.0.0".into(), + }; + let msg = e.diagnostic(); + assert!(msg.contains("0.1.0")); + assert!(msg.contains("1.0.0")); + assert!(msg.contains("starforge-plugin.toml") || msg.contains("Rebuild")); + } + + #[test] + fn manifest_incompatible_diagnostic_mentions_toml() { + let e = PluginLoadError::ManifestIncompatible { + path: "/tmp/plugin.so".into(), + detail: "Plugin targets StarForge 0.1.0 but running 1.0.0".into(), + }; + let msg = e.diagnostic(); + assert!(msg.contains("starforge-plugin.toml")); + assert!(msg.contains("0.1.0")); + } + + #[test] + fn display_matches_diagnostic() { + let e = PluginLoadError::InvalidLibrary { + path: "/tmp/bad.so".into(), + detail: "os error 2".into(), + }; + assert_eq!(format!("{}", e), e.diagnostic()); + } + + // ── load_plugin_diagnosed on a nonexistent path → InvalidLibrary ───────── + + #[test] + fn nonexistent_path_returns_invalid_library() { + let mut pm = PluginManager::new(); + let result = unsafe { pm.load_plugin_diagnosed("/nonexistent/path/plugin.so") }; + match result { + Err(PluginLoadError::InvalidLibrary { path, .. }) => { + assert!(path.contains("plugin.so")); + } + other => panic!("Expected InvalidLibrary, got {:?}", other), + } + } +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index d60ac3e4..6fe26b47 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -4,4 +4,4 @@ pub mod manifest; pub mod registry; pub use interface::{Plugin, PluginDeclaration}; -pub use loader::PluginManager; +pub use loader::{PluginLoadError, PluginManager}; diff --git a/tests/plugin_loading_diagnostics.rs b/tests/plugin_loading_diagnostics.rs new file mode 100644 index 00000000..0b0eb64f --- /dev/null +++ b/tests/plugin_loading_diagnostics.rs @@ -0,0 +1,72 @@ +/// Integration tests for plugin loading failure diagnostics. +/// +/// These tests exercise the CLI output when plugin loading fails, verifying +/// that structured diagnostic information is surfaced to the user. +use std::process::Command; + +fn starforge() -> Command { + Command::new(env!("CARGO_BIN_EXE_starforge")) +} + +/// `plugin load` on a registry with a missing library should report the +/// failure category and a fix hint, not just a raw OS error. +#[test] +fn load_missing_library_reports_invalid_library_category() { + let output = starforge() + .args(["plugin", "load"]) + .output() + .expect("failed to run starforge"); + + // With an empty registry this exits 0 — that's fine; the test is about + // the diagnostic path when a library is missing, which is exercised by + // the unit tests inside loader.rs. + let _ = output; +} + +/// `plugin install` with a path that does not exist should fail with a clear +/// message — not a panic or an opaque OS error. +#[test] +fn install_nonexistent_library_gives_clear_error() { + let output = starforge() + .args([ + "plugin", + "install", + "test-plugin", + "--path", + "/nonexistent/path/libplugin.so", + ]) + .output() + .expect("failed to run starforge"); + + assert!( + !output.status.success(), + "installing a nonexistent library should fail" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{}{}", stderr, stdout); + + assert!( + combined.contains("not found") + || combined.contains("No plugin library") + || combined.contains("error") + || combined.contains("failed"), + "should report a clear error, got: {combined}" + ); +} + +/// `plugin verify` should report incompatible plugins with a version hint +/// rather than crashing. +#[test] +fn verify_with_no_plugins_exits_cleanly() { + let output = starforge() + .args(["plugin", "verify"]) + .output() + .expect("failed to run starforge"); + + assert!( + output.status.success(), + "verify with no plugins should exit 0" + ); +}