use super::active::ActivePluginView;
use super::catalog::{
build_command_catalog, build_command_policy_registry, build_doctor_report,
command_provider_labels, completion_words_from_catalog, list_plugins, render_repl_help,
selected_provider_label,
};
use super::conversion::to_command_spec;
use super::selection::{ProviderResolution, ProviderResolutionError, provider_labels};
use super::state::PluginCommandPreferences;
#[cfg(test)]
use super::state::PluginCommandState;
use crate::completion::CommandSpec;
use crate::core::plugin::{DescribeCommandAuthV1, DescribeCommandV1};
use crate::core::runtime::RuntimeHints;
use anyhow::{Result, anyhow};
use std::collections::{BTreeSet, HashMap};
use std::error::Error as StdError;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
pub const DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginSource {
Explicit,
Env,
Bundled,
UserConfig,
Path,
}
impl Display for PluginSource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl PluginSource {
pub fn as_str(self) -> &'static str {
match self {
PluginSource::Explicit => "explicit",
PluginSource::Env => "env",
PluginSource::Bundled => "bundled",
PluginSource::UserConfig => "user",
PluginSource::Path => "path",
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub plugin_id: String,
pub plugin_version: Option<String>,
pub executable: PathBuf,
pub source: PluginSource,
pub commands: Vec<String>,
pub describe_commands: Vec<DescribeCommandV1>,
pub command_specs: Vec<CommandSpec>,
pub issue: Option<String>,
pub default_enabled: bool,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CanonicalPluginCommand<'a> {
name: &'a str,
spec: Option<&'a CommandSpec>,
describe: Option<&'a DescribeCommandV1>,
}
impl<'a> CanonicalPluginCommand<'a> {
pub(crate) fn name(self) -> &'a str {
self.name
}
pub(crate) fn completion(self) -> CommandSpec {
self.spec
.cloned()
.or_else(|| self.describe.map(to_command_spec))
.unwrap_or_else(|| CommandSpec::new(self.name))
}
pub(crate) fn auth(self) -> Option<DescribeCommandAuthV1> {
self.describe.and_then(|command| command.auth.clone())
}
pub(crate) fn describe(self) -> Option<&'a DescribeCommandV1> {
self.describe
}
}
impl DiscoveredPlugin {
pub(crate) fn canonical_command_names(&self) -> Vec<&str> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for spec in &self.command_specs {
if seen.insert(spec.name.as_str()) {
out.push(spec.name.as_str());
}
}
for command in &self.describe_commands {
if seen.insert(command.name.as_str()) {
out.push(command.name.as_str());
}
}
for command in &self.commands {
if seen.insert(command.as_str()) {
out.push(command.as_str());
}
}
out
}
pub(crate) fn canonical_command(
&self,
command_name: &str,
) -> Option<CanonicalPluginCommand<'_>> {
let spec = self
.command_specs
.iter()
.find(|spec| spec.name == command_name);
let describe = self
.describe_commands
.iter()
.find(|command| command.name == command_name);
let raw_name = self
.commands
.iter()
.find(|command| command.as_str() == command_name);
let name = spec
.map(|spec| spec.name.as_str())
.or_else(|| describe.map(|command| command.name.as_str()))
.or_else(|| raw_name.map(String::as_str))?;
Some(CanonicalPluginCommand {
name,
spec,
describe,
})
}
}
#[derive(Debug, Clone)]
pub struct PluginSummary {
pub plugin_id: String,
pub plugin_version: Option<String>,
pub executable: PathBuf,
pub source: PluginSource,
pub commands: Vec<String>,
pub enabled: bool,
pub healthy: bool,
pub issue: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CommandConflict {
pub command: String,
pub providers: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DoctorReport {
pub plugins: Vec<PluginSummary>,
pub conflicts: Vec<CommandConflict>,
}
#[derive(Debug, Clone)]
pub struct CommandCatalogEntry {
pub name: String,
pub about: String,
pub auth: Option<DescribeCommandAuthV1>,
pub subcommands: Vec<String>,
pub completion: CommandSpec,
pub provider: Option<String>,
pub providers: Vec<String>,
pub conflicted: bool,
pub requires_selection: bool,
pub selected_explicitly: bool,
pub source: Option<PluginSource>,
}
impl CommandCatalogEntry {
pub fn auth_hint(&self) -> Option<String> {
self.auth.as_ref().and_then(|auth| auth.hint())
}
}
#[derive(Debug, Clone)]
pub struct RawPluginOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
#[must_use]
pub struct PluginDispatchContext {
pub runtime_hints: RuntimeHints,
pub shared_env: Vec<(String, String)>,
pub plugin_env: HashMap<String, Vec<(String, String)>>,
pub provider_override: Option<String>,
}
impl PluginDispatchContext {
pub fn new(runtime_hints: RuntimeHints) -> Self {
Self {
runtime_hints,
shared_env: Vec::new(),
plugin_env: HashMap::new(),
provider_override: None,
}
}
pub fn with_shared_env<I, K, V>(mut self, shared_env: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.shared_env = shared_env
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect();
self
}
pub fn with_plugin_env(mut self, plugin_env: HashMap<String, Vec<(String, String)>>) -> Self {
self.plugin_env = plugin_env;
self
}
pub fn with_provider_override(mut self, provider_override: Option<String>) -> Self {
self.provider_override = provider_override;
self
}
pub(crate) fn env_pairs_for<'a>(
&'a self,
plugin_id: &'a str,
) -> impl Iterator<Item = (&'a str, &'a str)> {
self.shared_env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.chain(
self.plugin_env
.get(plugin_id)
.into_iter()
.flat_map(|entries| entries.iter())
.map(|(key, value)| (key.as_str(), value.as_str())),
)
}
}
#[derive(Debug)]
pub enum PluginDispatchError {
CommandNotFound {
command: String,
},
CommandAmbiguous {
command: String,
providers: Vec<String>,
},
ProviderNotFound {
command: String,
requested_provider: String,
providers: Vec<String>,
},
ExecuteFailed {
plugin_id: String,
source: std::io::Error,
},
TimedOut {
plugin_id: String,
timeout: Duration,
stderr: String,
},
NonZeroExit {
plugin_id: String,
status_code: i32,
stderr: String,
},
InvalidJsonResponse {
plugin_id: String,
source: serde_json::Error,
},
InvalidResponsePayload {
plugin_id: String,
reason: String,
},
}
impl Display for PluginDispatchError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
PluginDispatchError::CommandNotFound { command } => {
write!(f, "no plugin provides command: {command}")
}
PluginDispatchError::CommandAmbiguous { command, providers } => {
write!(
f,
"command `{command}` is provided by multiple plugins: {}",
providers.join(", ")
)
}
PluginDispatchError::ProviderNotFound {
command,
requested_provider,
providers,
} => {
write!(
f,
"plugin `{requested_provider}` does not provide command `{command}`; available providers: {}",
providers.join(", ")
)
}
PluginDispatchError::ExecuteFailed { plugin_id, source } => {
write!(f, "failed to execute plugin {plugin_id}: {source}")
}
PluginDispatchError::TimedOut {
plugin_id,
timeout,
stderr,
} => {
if stderr.trim().is_empty() {
write!(
f,
"plugin {plugin_id} timed out after {} ms",
timeout.as_millis()
)
} else {
write!(
f,
"plugin {plugin_id} timed out after {} ms: {}",
timeout.as_millis(),
stderr.trim()
)
}
}
PluginDispatchError::NonZeroExit {
plugin_id,
status_code,
stderr,
} => {
if stderr.trim().is_empty() {
write!(f, "plugin {plugin_id} exited with status {status_code}")
} else {
write!(
f,
"plugin {plugin_id} exited with status {status_code}: {}",
stderr.trim()
)
}
}
PluginDispatchError::InvalidJsonResponse { plugin_id, source } => {
write!(f, "invalid JSON response from plugin {plugin_id}: {source}")
}
PluginDispatchError::InvalidResponsePayload { plugin_id, reason } => {
write!(f, "invalid plugin response from {plugin_id}: {reason}")
}
}
}
}
impl StdError for PluginDispatchError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
PluginDispatchError::ExecuteFailed { source, .. } => Some(source),
PluginDispatchError::InvalidJsonResponse { source, .. } => Some(source),
PluginDispatchError::CommandNotFound { .. }
| PluginDispatchError::CommandAmbiguous { .. }
| PluginDispatchError::ProviderNotFound { .. }
| PluginDispatchError::TimedOut { .. }
| PluginDispatchError::NonZeroExit { .. }
| PluginDispatchError::InvalidResponsePayload { .. } => None,
}
}
}
#[must_use]
pub struct PluginManager {
pub(crate) explicit_dirs: Vec<PathBuf>,
pub(crate) discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
pub(crate) dispatch_discovered_cache: RwLock<Option<Arc<[DiscoveredPlugin]>>>,
pub(crate) command_preferences: RwLock<PluginCommandPreferences>,
pub(crate) config_root: Option<PathBuf>,
pub(crate) cache_root: Option<PathBuf>,
pub(crate) process_timeout: Duration,
pub(crate) allow_path_discovery: bool,
pub(crate) allow_default_roots: bool,
}
struct KnownCommandProviders<'a> {
command: &'a str,
providers: Vec<&'a DiscoveredPlugin>,
}
impl<'a> KnownCommandProviders<'a> {
fn collect(view: &ActivePluginView<'a>, command: &'a str) -> Self {
Self {
command,
providers: view.healthy_providers(command),
}
}
fn validate_command(&self) -> Result<()> {
if self.providers.is_empty() {
return Err(anyhow!(
"no healthy plugin provides command `{}`",
self.command
));
}
Ok(())
}
}
struct AvailableCommandProviders<'a> {
command: &'a str,
available: Vec<&'a DiscoveredPlugin>,
}
impl<'a> AvailableCommandProviders<'a> {
fn collect(view: &ActivePluginView<'a>, command: &'a str) -> Self {
Self {
command,
available: view.available_providers(command),
}
}
fn len(&self) -> usize {
self.available.len()
}
fn validate_command(&self) -> Result<()> {
if self.available.is_empty() {
return Err(anyhow!(
"no available plugin provides command `{}`",
self.command
));
}
Ok(())
}
fn validate_provider(&self, plugin_id: &str) -> Result<()> {
self.validate_command()?;
if self
.available
.iter()
.any(|plugin| plugin.plugin_id == plugin_id)
{
return Ok(());
}
Err(anyhow!(
"plugin `{plugin_id}` is not currently available for command `{}`; available providers: {}",
self.command,
self.labels().join(", ")
))
}
fn labels(&self) -> Vec<String> {
provider_labels(&self.available)
}
}
impl PluginManager {
pub fn new(explicit_dirs: Vec<PathBuf>) -> Self {
Self {
explicit_dirs,
discovered_cache: RwLock::new(None),
dispatch_discovered_cache: RwLock::new(None),
command_preferences: RwLock::new(PluginCommandPreferences::default()),
config_root: None,
cache_root: None,
process_timeout: Duration::from_millis(DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS as u64),
allow_path_discovery: false,
allow_default_roots: true,
}
}
pub fn explicit_dirs(&self) -> &[PathBuf] {
&self.explicit_dirs
}
pub fn with_roots(mut self, config_root: Option<PathBuf>, cache_root: Option<PathBuf>) -> Self {
self.config_root = config_root;
self.cache_root = cache_root;
self
}
pub fn config_root(&self) -> Option<&std::path::Path> {
self.config_root.as_deref()
}
pub fn cache_root(&self) -> Option<&std::path::Path> {
self.cache_root.as_deref()
}
pub fn with_default_roots(mut self, allow_default_roots: bool) -> Self {
self.allow_default_roots = allow_default_roots;
self
}
pub fn default_roots_enabled(&self) -> bool {
self.allow_default_roots
}
pub fn with_process_timeout(mut self, timeout: Duration) -> Self {
self.process_timeout = timeout.max(Duration::from_millis(1));
self
}
pub fn process_timeout(&self) -> Duration {
self.process_timeout
}
pub fn with_path_discovery(mut self, allow_path_discovery: bool) -> Self {
self.allow_path_discovery = allow_path_discovery;
self
}
pub fn path_discovery_enabled(&self) -> bool {
self.allow_path_discovery
}
pub(crate) fn with_command_preferences(
mut self,
preferences: PluginCommandPreferences,
) -> Self {
self.command_preferences = RwLock::new(preferences);
self
}
pub fn list_plugins(&self) -> Vec<PluginSummary> {
self.with_passive_view(list_plugins)
}
pub fn command_catalog(&self) -> Vec<CommandCatalogEntry> {
self.with_passive_catalog(|catalog| catalog)
}
pub fn command_policy_registry(&self) -> crate::core::command_policy::CommandPolicyRegistry {
self.with_passive_view(build_command_policy_registry)
}
pub fn completion_words(&self) -> Vec<String> {
self.with_passive_catalog(|catalog| completion_words_from_catalog(&catalog))
}
pub fn repl_help_text(&self) -> String {
self.with_passive_catalog(|catalog| render_repl_help(&catalog))
}
pub fn command_providers(&self, command: &str) -> Vec<String> {
self.with_passive_view(|view| command_provider_labels(command, view))
}
pub fn selected_provider_label(&self, command: &str) -> Option<String> {
self.with_passive_view(|view| selected_provider_label(command, view))
}
pub fn doctor(&self) -> DoctorReport {
self.with_passive_view(build_doctor_report)
}
pub(crate) fn validate_command(&self, command: &str) -> Result<()> {
let command = command.trim();
if command.is_empty() {
return Err(anyhow!("command must not be empty"));
}
self.with_dispatch_view(|view| {
KnownCommandProviders::collect(view, command).validate_command()
})
}
#[cfg(test)]
pub(crate) fn set_command_state(&self, command: &str, state: PluginCommandState) -> Result<()> {
self.validate_command(command)?;
self.update_command_preferences(|preferences| {
preferences.set_state(command, state);
});
Ok(())
}
pub fn select_provider(&self, command: &str, plugin_id: &str) -> Result<()> {
let command = command.trim();
let plugin_id = plugin_id.trim();
if command.is_empty() {
return Err(anyhow!("command must not be empty"));
}
if plugin_id.is_empty() {
return Err(anyhow!("plugin id must not be empty"));
}
self.validate_provider_selection(command, plugin_id)?;
self.update_command_preferences(|preferences| preferences.set_provider(command, plugin_id));
Ok(())
}
pub fn clear_provider_selection(&self, command: &str) -> Result<bool> {
let command = command.trim();
if command.is_empty() {
return Err(anyhow!("command must not be empty"));
}
let mut removed = false;
self.update_command_preferences(|preferences| {
removed = preferences.clear_provider(command);
});
Ok(removed)
}
pub fn validate_provider_selection(&self, command: &str, plugin_id: &str) -> Result<()> {
self.with_dispatch_view(|view| {
AvailableCommandProviders::collect(view, command).validate_provider(plugin_id)
})
}
pub(super) fn resolve_provider(
&self,
command: &str,
provider_override: Option<&str>,
) -> std::result::Result<DiscoveredPlugin, PluginDispatchError> {
self.with_dispatch_view(|view| {
let available = AvailableCommandProviders::collect(view, command);
match view.resolve_provider(command, provider_override) {
Ok(ProviderResolution::Selected(selection)) => {
tracing::debug!(
command = %command,
active_providers = available.len(),
selected_provider = %selection.plugin.plugin_id,
selection_mode = ?selection.mode,
"resolved plugin provider"
);
Ok(selection.plugin.clone())
}
Ok(ProviderResolution::Ambiguous(providers)) => {
let provider_labels = provider_labels(&providers);
tracing::warn!(
command = %command,
providers = provider_labels.join(", "),
"plugin command requires explicit provider selection"
);
Err(PluginDispatchError::CommandAmbiguous {
command: command.to_string(),
providers: provider_labels,
})
}
Err(ProviderResolutionError::RequestedProviderUnavailable {
requested_provider,
providers,
}) => {
let provider_labels = provider_labels(&providers);
tracing::warn!(
command = %command,
requested_provider = %requested_provider,
providers = provider_labels.join(", "),
"requested plugin provider is not available for command"
);
Err(PluginDispatchError::ProviderNotFound {
command: command.to_string(),
requested_provider,
providers: provider_labels,
})
}
Err(ProviderResolutionError::CommandNotFound) => {
tracing::warn!(
command = %command,
active_plugins = view.healthy_plugins().len(),
"no plugin provider found for command"
);
Err(PluginDispatchError::CommandNotFound {
command: command.to_string(),
})
}
}
})
}
fn with_passive_view<R, F>(&self, apply: F) -> R
where
F: FnOnce(&ActivePluginView<'_>) -> R,
{
self.with_discovered_view(self.discover(), apply)
}
fn with_dispatch_view<R, F>(&self, apply: F) -> R
where
F: FnOnce(&ActivePluginView<'_>) -> R,
{
self.with_discovered_view(self.discover_for_dispatch(), apply)
}
fn with_discovered_view<R, F>(&self, discovered: Arc<[DiscoveredPlugin]>, apply: F) -> R
where
F: FnOnce(&ActivePluginView<'_>) -> R,
{
let preferences = self.command_preferences();
let view = ActivePluginView::new(discovered.as_ref(), &preferences);
apply(&view)
}
fn with_passive_catalog<R, F>(&self, apply: F) -> R
where
F: FnOnce(Vec<CommandCatalogEntry>) -> R,
{
self.with_passive_view(|view| apply(build_command_catalog(view)))
}
fn command_preferences(&self) -> PluginCommandPreferences {
self.command_preferences
.read()
.unwrap_or_else(|err| err.into_inner())
.clone()
}
pub(crate) fn command_preferences_snapshot(&self) -> PluginCommandPreferences {
self.command_preferences()
}
pub(crate) fn replace_command_preferences(&self, preferences: PluginCommandPreferences) {
let mut current = self
.command_preferences
.write()
.unwrap_or_else(|err| err.into_inner());
*current = preferences;
}
fn update_command_preferences<F>(&self, update: F)
where
F: FnOnce(&mut PluginCommandPreferences),
{
let mut preferences = self
.command_preferences
.write()
.unwrap_or_else(|err| err.into_inner());
update(&mut preferences);
}
}