diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 0ec93a27..0a13c6b6 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -5,7 +5,7 @@ use crate::plugins::{PluginLoadError, PluginManager}; use crate::utils::print as p; use anyhow::Result; use clap::Subcommand; -use colored::*; +use starforge::utils::config; use std::path::Path; use std::path::PathBuf; @@ -113,8 +113,8 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { fn install(name: String, path: Option, source: Option, force: bool) -> Result<()> { let lib_path = registry::resolve_plugin_library_path(&name, path)?; let source_str = source.as_deref().unwrap_or(""); - let config = crate::utils::config::load().unwrap_or_default(); - let trust = registry::classify_source(source_str); + let config = config::load().unwrap_or_default(); + let trust = registry::classify_source_with_config(source_str, &config); // Warn the user about untrusted sources and require --force to proceed. if trust == TrustLevel::Unknown && !source_str.is_empty() && !force { @@ -135,15 +135,31 @@ fn install(name: String, path: Option, source: Option, force: b let plugin_manifest = manifest::require_compatible_manifest(&lib_path, &name)?; - let discovered_commands = discover_commands_from_library(lib_path.to_str().unwrap_or_default()) - .unwrap_or_else(|e| { - p::warn(&format!( - "Could not discover commands from '{}': {}", - lib_path.display(), - e - )); - Vec::new() - }); + // Load the plugin to discover the commands and description it registers. + let (discovered_commands, plugin_description) = { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(&lib_path).with_context(|| { + format!("Failed to load plugin '{}' to discover commands", name) + })?; + } + let commands: Vec = pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect(); + let description = pm + .list_plugins() + .into_iter() + .find(|(plugin_name, _, _)| *plugin_name == name) + .map(|(_, desc, _)| desc.to_string()) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| plugin_manifest.description.clone()); + (commands, description) + }; registry::install_plugin( &name, @@ -151,6 +167,7 @@ fn install(name: String, path: Option, source: Option, force: b source_str, &plugin_manifest.starforge_version, &plugin_manifest.version, + &plugin_description, discovered_commands.clone(), )?; @@ -187,20 +204,41 @@ fn list() -> Result<()> { p::kv("StarForge core version", CORE_VERSION); p::separator(); - for (i, pl) in reg.plugins.iter().enumerate() { - println!(" {:>2}. {}", i + 1, pl.name); - p::kv("Path", &pl.path); - p::kv("Trust", pl.trust.label()); - if !pl.source.is_empty() { - p::kv("Source", &pl.source); - } - if !pl.starforge_version.is_empty() { - p::kv("StarForge", &pl.starforge_version); - } - if i < reg.plugins.len() - 1 { - println!(); - } + + let entries = registry::plugin_list_entries(®); + + let plugin_rows: Vec> = entries + .iter() + .map(|entry| { + vec![ + entry.name.clone(), + entry.version.clone(), + entry.trust.label().to_string(), + entry.description.clone(), + ] + }) + .collect(); + p::table(&["Name", "Version", "Trust", "Description"], &plugin_rows); + + let command_rows: Vec> = entries + .iter() + .flat_map(|entry| { + entry.commands.iter().map(|cmd| { + vec![ + entry.name.clone(), + cmd.name.clone(), + cmd.description.clone(), + ] + }) + }) + .collect(); + + if !command_rows.is_empty() { + println!(); + p::info("Commands"); + p::table(&["Plugin", "Command", "Description"], &command_rows); } + p::separator(); Ok(()) } @@ -253,6 +291,8 @@ fn load() -> Result<()> { return Ok(()); } + let config = config::load().unwrap_or_default(); + // Warn about any unknown-trust plugins before loading. for pl in reg.plugins.iter().filter(|p| { registry::classify_source(&p.source) == TrustLevel::Unknown && !p.source.is_empty() @@ -404,6 +444,8 @@ fn update(name: Option, yes: bool) -> Result<()> { return Ok(()); } + let config = config::load().unwrap_or_default(); + let to_update: Vec<_> = match &name { Some(n) => { let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); @@ -491,6 +533,7 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.source, &pl.starforge_version, &pl.plugin_version, + &pl.description, pl.commands.clone(), )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); @@ -531,14 +574,15 @@ fn update(name: Option, yes: bool) -> Result<()> { 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()); + let (cmds, description) = discover_plugin_metadata(&pl.path) + .unwrap_or_else(|_| (pl.commands.clone(), pl.description.clone())); registry::install_plugin( &pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version, + &description, cmds, )?; p::success(&format!( @@ -604,6 +648,7 @@ fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { None => reg.plugins.iter().collect(), }; + let config = config::load().unwrap_or_default(); let mut all_ok = true; for pl in &to_check { @@ -931,41 +976,74 @@ fn print_audit_report(report: &AuditReport) { println!(); } -fn commands(_name: Option) -> Result<()> { +fn discover_plugin_metadata(path: &str) -> Result<(Vec, String)> { + let mut pm = PluginManager::new(); + unsafe { + pm.load_plugin(path) + .with_context(|| format!("Failed to load plugin from {}", path))?; + } + let commands = pm + .list_commands() + .into_iter() + .map(|c| RegisteredCommand { + name: c.name, + description: c.description, + }) + .collect(); + let description = pm + .list_plugins() + .into_iter() + .map(|(_, desc, _)| desc.to_string()) + .find(|d| !d.is_empty()) + .unwrap_or_default(); + Ok((commands, description)) +} + +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(()); } - p::separator(); - for plugin in ®.plugins { - p::kv_accent("Plugin", &plugin.name); - if !plugin.commands.is_empty() { - for cmd in &plugin.commands { - p::info(&format!(" • {} — {}", cmd.name, cmd.description)); + let entries: Vec<_> = match &name { + Some(n) => { + let found: Vec<_> = registry::plugin_list_entries(®) + .into_iter() + .filter(|entry| entry.name == *n) + .collect(); + if found.is_empty() { + anyhow::bail!( + "Plugin '{}' is not installed. Run `starforge plugin list`.", + n + ); } - } else { - p::info(" (no commands registered)"); + found } - println!(); - } - p::separator(); - Ok(()) -} + None => registry::plugin_list_entries(®), + }; -fn discover_commands_from_library(path: &str) -> Result> { - let mut pm = PluginManager::new(); - unsafe { - pm.load_plugin(path)?; - } - Ok(pm - .list_commands() - .into_iter() - .map(|c| RegisteredCommand { - name: c.name, - description: c.description, + let rows: Vec> = entries + .iter() + .flat_map(|entry| { + entry.commands.iter().map(|cmd| { + vec![ + entry.name.clone(), + cmd.name.clone(), + cmd.description.clone(), + ] + }) }) - .collect()) + .collect(); + + if rows.is_empty() { + p::info("No commands registered. Re-install plugins to discover their commands."); + p::info(" starforge plugin install --path "); + return Ok(()); + } + + p::table(&["Plugin", "Command", "Description"], &rows); + Ok(()) } diff --git a/src/main.rs b/src/main.rs index e864acd9..9fc1d7d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,8 @@ )] mod commands; -pub use starforge::{plugins, utils}; +pub use starforge::plugins; +pub use starforge::utils; use clap::{Parser, Subcommand}; use colored::*; @@ -214,6 +215,7 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { let plugin_name = &args[0]; let plugin_args = &args[1..]; + let cfg = starforge::utils::config::load()?; let reg = plugins::registry::load_registry().unwrap_or_default(); if reg.plugins.is_empty() { anyhow::bail!( diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index f7368888..2de468d1 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -1,3 +1,5 @@ +use crate::plugins::manifest; +use crate::utils::config::{self, Config}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; @@ -210,6 +212,57 @@ pub struct InstalledPlugin { /// Commands registered by the plugin at install time. #[serde(default)] pub commands: Vec, + /// Plugin description from `Plugin::description()` at install time. + #[serde(default)] + pub description: String, +} + +/// Bundled metadata for `starforge plugin list` table output. +#[derive(Debug, Clone)] +pub struct PluginListEntry { + pub name: String, + pub version: String, + pub trust: TrustLevel, + pub description: String, + pub commands: Vec, +} + +/// Resolve a display description using registry data and on-disk manifest fallbacks. +pub fn resolve_plugin_description(plugin: &InstalledPlugin) -> String { + if !plugin.description.is_empty() { + return plugin.description.clone(); + } + + if let Ok(Some(mf)) = manifest::load_manifest_for_library(Path::new(&plugin.path)) { + if !mf.description.is_empty() { + return mf.description; + } + } + + plugin + .commands + .first() + .map(|c| c.description.clone()) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| "(none)".to_string()) +} + +/// Build table rows for every installed plugin. +pub fn plugin_list_entries(reg: &PluginRegistry) -> Vec { + reg.plugins + .iter() + .map(|p| PluginListEntry { + name: p.name.clone(), + version: if p.plugin_version.is_empty() { + "—".to_string() + } else { + p.plugin_version.clone() + }, + trust: p.trust.clone(), + description: resolve_plugin_description(p), + commands: p.commands.clone(), + }) + .collect() } fn registry_path() -> Result { @@ -282,12 +335,14 @@ 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()`). +/// `description` is the plugin summary from `Plugin::description()`. pub fn install_plugin( name: &str, library_path: &Path, source: &str, starforge_version: &str, plugin_version: &str, + description: &str, commands: Vec, ) -> Result<()> { if !library_path.exists() { @@ -308,6 +363,7 @@ pub fn install_plugin( plugin_version: plugin_version.to_string(), installed_at: Some(now), commands, + description: description.to_string(), }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -509,7 +565,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", vec![]); + 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")); } @@ -546,6 +602,10 @@ mod tests { plugin.commands.is_empty(), "missing commands field should default to an empty list" ); + assert_eq!( + plugin.description, "", + "missing description field should default to empty string" + ); } // ── resolve_plugin_library_path ─────────────────────────────────────────── @@ -564,6 +624,69 @@ mod tests { assert!(result.is_err()); } + #[test] + fn resolve_plugin_description_prefers_registry_field() { + let plugin = InstalledPlugin { + name: "demo".into(), + path: "/tmp/demo.so".into(), + source: String::new(), + trust: TrustLevel::Local, + starforge_version: String::new(), + plugin_version: String::new(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "demo".into(), + description: "from command".into(), + }], + description: "from plugin".into(), + }; + assert_eq!(resolve_plugin_description(&plugin), "from plugin"); + } + + #[test] + fn resolve_plugin_description_falls_back_to_first_command() { + let plugin = InstalledPlugin { + name: "demo".into(), + path: "/tmp/demo.so".into(), + source: String::new(), + trust: TrustLevel::Local, + starforge_version: String::new(), + plugin_version: String::new(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "demo".into(), + description: "from command".into(), + }], + description: String::new(), + }; + assert_eq!(resolve_plugin_description(&plugin), "from command"); + } + + #[test] + fn plugin_list_entries_include_resolved_description() { + let reg = PluginRegistry { + plugins: vec![InstalledPlugin { + name: "trusted".into(), + path: "/tmp/trusted.so".into(), + source: String::new(), + trust: TrustLevel::Trusted, + starforge_version: "0.1.0".into(), + plugin_version: "1.0.0".into(), + installed_at: None, + commands: vec![RegisteredCommand { + name: "trusted".into(), + description: "Lifecycle integration test plugin".into(), + }], + description: "Lifecycle integration test plugin".into(), + }], + }; + + let entries = plugin_list_entries(®); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].description, "Lifecycle integration test plugin"); + assert_eq!(entries[0].commands[0].name, "trusted"); + } + // ── backward compatibility ──────────────────────────────────────────────── #[test] diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index ed01d818..d0511a66 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -675,7 +675,7 @@ mod tests { #[test] fn build_transaction_query_url_includes_pagination_params() { - let server = Server::new(); + let mut server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let filter = TxFilter { diff --git a/src/utils/print.rs b/src/utils/print.rs index 9b34bd29..862f0ee5 100644 --- a/src/utils/print.rs +++ b/src/utils/print.rs @@ -81,3 +81,70 @@ pub fn verified_badge(verified: bool) -> colored::ColoredString { "".normal() } } + +/// Print an aligned table with dimmed headers and bright row values. +pub fn table(headers: &[&str], rows: &[Vec]) { + if headers.is_empty() { + return; + } + + let ncol = headers.len(); + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate().take(ncol) { + widths[i] = widths[i].max(cell.len()); + } + } + + let header_line = headers + .iter() + .enumerate() + .map(|(i, h)| format!("{:>() + .join(" "); + println!(" {}", header_line.dimmed()); + + for row in rows { + let line = (0..ncol) + .map(|i| { + let val = row.get(i).map(String::as_str).unwrap_or(""); + format!("{:>() + .join(" "); + println!(" {}", line.bright_white()); + } +} + +#[cfg(test)] +mod tests { + fn column_widths(headers: &[&str], rows: &[Vec]) -> Vec { + let ncol = headers.len(); + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate().take(ncol) { + widths[i] = widths[i].max(cell.len()); + } + } + widths + } + + #[test] + fn table_widths_use_header_and_cell_maxima() { + let widths = column_widths( + &["Name", "Description"], + &[vec![ + "trusted".into(), + "Lifecycle integration test plugin".into(), + ]], + ); + assert_eq!(widths[0], "trusted".len()); + assert_eq!(widths[1], "Lifecycle integration test plugin".len()); + } + + #[test] + fn table_widths_handle_empty_rows() { + let widths = column_widths(&["Name", "Version"], &[]); + assert_eq!(widths, vec![4, 7]); + } +} diff --git a/tests/plugin_lifecycle_e2e.rs b/tests/plugin_lifecycle_e2e.rs index 088cc243..5d79a7ce 100644 --- a/tests/plugin_lifecycle_e2e.rs +++ b/tests/plugin_lifecycle_e2e.rs @@ -192,7 +192,9 @@ fn plugin_lifecycle_install_list_verify_load_uninstall() { .output() .expect("run plugin list"); assert_success(&list, "plugin list"); - assert!(String::from_utf8_lossy(&list.stdout).contains("trusted")); + let list_stdout = String::from_utf8_lossy(&list.stdout); + assert!(list_stdout.contains("trusted")); + assert!(list_stdout.contains("Lifecycle integration test plugin")); let verify = starforge(home.path()) .args(["plugin", "verify", "trusted", "--deep"])