//! Slash-command registry and native execution planning. //! //! The registry is transport-neutral and can later receive plugin-provided //! commands without changing the frontend-facing list/filter contract. use domain::{ filter_slash_commands, native_slash_commands, plugin_slash_command, NativeSlashCommand, SessionId, SlashCommand, SlashCommandAvailability, SlashCommandSource, }; use serde_json::Value; use crate::{AppError, PluginRuntimeCatalog}; /// Lists slash commands. #[derive(Debug, Clone, Default)] pub struct ListSlashCommandsInput { /// Optional typed prefix, with or without the leading `/`. pub prefix: Option, } /// Slash-command list output. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSlashCommandsOutput { /// Filtered commands in deterministic UI order. pub commands: Vec, } /// Executes a native slash command. #[derive(Debug, Clone)] pub struct ExecuteSlashCommandInput { /// Command name, with or without the leading `/`. pub name: String, /// Current structured chat session, required by conversation-local commands. pub session_id: Option, /// Opaque arguments forwarded to plugin callbacks. pub arguments: Vec, } /// Execution output: application decides intent; adapters perform transport-only /// side effects when needed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecuteSlashCommandOutput { /// Canonical command name. pub command: SlashCommand, /// Planned effect. pub effect: SlashCommandEffect, } /// Native command effects understood by driving adapters and the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCommandEffect { /// Return the unified command list. Help { /// Commands to display. commands: Vec, }, /// Clear one live structured conversation. CleanConversation { /// Session whose retained conversation view must be cleared. session_id: SessionId, }, /// Placeholder for ticket #164. ProfileSwitch { /// Session whose agent/profile flow is targeted. session_id: SessionId, }, /// Dispatch to a plugin-owned callback. The plugin decides what the callback does. PluginCallback { /// Plugin id owning the callback. plugin_id: String, /// SDK command callback id. command_id: String, /// Current structured chat session, when available. session_id: Option, /// Opaque arguments supplied by the caller. arguments: Vec, }, } /// In-memory slash-command registry. #[derive(Debug, Clone, Default)] pub struct SlashCommandRegistry { plugin_commands: Vec, } impl SlashCommandRegistry { /// Creates an empty registry with IdeA native commands. #[must_use] pub const fn new() -> Self { Self { plugin_commands: Vec::new(), } } /// Creates a registry with already-projected plugin commands. #[must_use] pub fn with_plugin_commands(plugin_commands: Vec) -> Self { Self { plugin_commands } } /// Returns all commands from all sources in deterministic order. #[must_use] pub fn all(&self) -> Vec { let mut commands = native_slash_commands(); commands.extend(self.plugin_commands.clone()); commands } /// Returns commands filtered by prefix. #[must_use] pub fn list(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput { ListSlashCommandsOutput { commands: filter_slash_commands(self.all(), input.prefix.as_deref()), } } /// Plans execution for a command. /// /// # Errors /// Returns [`AppError::NotFound`] for an unknown command, [`AppError::Invalid`] /// for unavailable commands or missing command-local context. pub fn execute( &self, input: ExecuteSlashCommandInput, ) -> Result { let name = normalize_command_name(&input.name)?; let command = self .all() .into_iter() .find(|command| command.name == name) .ok_or_else(|| AppError::NotFound(format!("slash command {name}")))?; if let SlashCommandAvailability::Unavailable { reason } = &command.availability { return Err(AppError::Invalid(format!( "slash command {name} unavailable: {reason}" ))); } if let Some(plugin) = command.plugin.clone() { return Ok(ExecuteSlashCommandOutput { command, effect: SlashCommandEffect::PluginCallback { plugin_id: plugin.plugin_id, command_id: plugin.command_id, session_id: input.session_id, arguments: input.arguments, }, }); } let Some(native) = command.native else { return Err(AppError::Invalid(format!( "slash command {name} has no executable adapter" ))); }; let effect = match native { NativeSlashCommand::Help => SlashCommandEffect::Help { commands: self.all(), }, NativeSlashCommand::Clean => SlashCommandEffect::CleanConversation { session_id: input.session_id.ok_or_else(|| { AppError::Invalid("/clean requires a current session id".to_owned()) })?, }, NativeSlashCommand::Profile => SlashCommandEffect::ProfileSwitch { session_id: input.session_id.ok_or_else(|| { AppError::Invalid("/profile requires a current session id".to_owned()) })?, }, }; Ok(ExecuteSlashCommandOutput { command, effect }) } } fn normalize_command_name(name: &str) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { return Err(AppError::Invalid("slash command name is empty".to_owned())); } Ok(if trimmed.starts_with('/') { trimmed.to_owned() } else { format!("/{trimmed}") }) } /// Use case wrapper for list/filter. #[derive(Debug, Clone, Default)] pub struct ListSlashCommands { registry: SlashCommandRegistry, } impl ListSlashCommands { /// Builds the use case. #[must_use] pub const fn new(registry: SlashCommandRegistry) -> Self { Self { registry } } /// Executes the use case. #[must_use] pub fn execute(&self, input: ListSlashCommandsInput) -> ListSlashCommandsOutput { self.registry.list(input) } /// Executes the use case with runtime plugin commands merged into the registry. #[must_use] pub fn execute_with_plugins( &self, input: ListSlashCommandsInput, plugin_commands: Vec, ) -> ListSlashCommandsOutput { SlashCommandRegistry::with_plugin_commands(plugin_commands).list(input) } } /// Use case wrapper for execution planning. #[derive(Debug, Clone, Default)] pub struct ExecuteSlashCommand { registry: SlashCommandRegistry, } impl ExecuteSlashCommand { /// Builds the use case. #[must_use] pub const fn new(registry: SlashCommandRegistry) -> Self { Self { registry } } /// Executes the use case. pub fn execute( &self, input: ExecuteSlashCommandInput, ) -> Result { self.registry.execute(input) } /// Executes the use case with runtime plugin commands merged into the registry. pub fn execute_with_plugins( &self, input: ExecuteSlashCommandInput, plugin_commands: Vec, ) -> Result { SlashCommandRegistry::with_plugin_commands(plugin_commands).execute(input) } } /// Projects active plugin runtime contributions into slash-command metadata. /// /// The callback is not executed here. Execution returns [`SlashCommandEffect::PluginCallback`] /// so the host SDK runtime can dispatch the plugin-owned handler. pub fn plugin_slash_commands_from_runtime_catalog( catalog: &PluginRuntimeCatalog, ) -> Result, AppError> { let mut commands = Vec::new(); for plugin in &catalog.plugins { for slash in &plugin.contributes.slash_commands { let command = plugin_slash_command( plugin.id.clone(), slash.command.as_str().to_owned(), slash.name.clone(), slash.short_description.clone(), slash.requires_confirmation, if slash.when.as_deref().is_some_and(str::is_empty) { SlashCommandAvailability::Unavailable { reason: "Plugin slash command condition is empty".to_owned(), } } else { SlashCommandAvailability::Available }, ) .map_err(|e| AppError::Invalid(e.to_string()))?; if !matches!(command.source, SlashCommandSource::Plugin { .. }) { return Err(AppError::Invalid( "plugin slash command must use plugin source".to_owned(), )); } commands.push(command); } } Ok(commands) } #[cfg(test)] mod tests { use super::*; use uuid::Uuid; fn session() -> SessionId { SessionId::from_uuid(Uuid::from_u128(42)) } #[test] fn list_filters_native_commands_by_prefix() { let out = ListSlashCommands::new(SlashCommandRegistry::new()).execute(ListSlashCommandsInput { prefix: Some("he".to_owned()), }); assert_eq!(out.commands.len(), 1); assert_eq!(out.commands[0].name, "/help"); } #[test] fn help_returns_unified_command_list() { let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) .execute(ExecuteSlashCommandInput { name: "help".to_owned(), session_id: None, arguments: Vec::new(), }) .unwrap(); assert_eq!(out.command.name, "/help"); let SlashCommandEffect::Help { commands } = out.effect else { panic!("help effect expected"); }; assert!(commands.iter().any(|command| command.name == "/clean")); } #[test] fn clean_requires_and_returns_current_session() { let sid = session(); let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) .execute(ExecuteSlashCommandInput { name: "/clean".to_owned(), session_id: Some(sid), arguments: Vec::new(), }) .unwrap(); assert_eq!( out.effect, SlashCommandEffect::CleanConversation { session_id: sid } ); } #[test] fn profile_returns_profile_switch_effect() { let sid = session(); let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) .execute(ExecuteSlashCommandInput { name: "/profile".to_owned(), session_id: Some(sid), arguments: Vec::new(), }) .unwrap(); assert_eq!( out.effect, SlashCommandEffect::ProfileSwitch { session_id: sid } ); } #[test] fn plugin_command_execution_returns_callback_effect() { let command = plugin_slash_command( "dev.acme.plugin", "dev.acme.plugin.explain", "/explain", "Explain selection", false, SlashCommandAvailability::Available, ) .unwrap(); let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) .execute_with_plugins( ExecuteSlashCommandInput { name: "/explain".to_owned(), session_id: Some(session()), arguments: vec![serde_json::json!({ "selection": "abc" })], }, vec![command], ) .unwrap(); let SlashCommandEffect::PluginCallback { plugin_id, command_id, session_id, arguments, } = out.effect else { panic!("plugin callback effect expected"); }; assert_eq!(plugin_id, "dev.acme.plugin"); assert_eq!(command_id, "dev.acme.plugin.explain"); assert_eq!(session_id, Some(session())); assert_eq!(arguments, vec![serde_json::json!({ "selection": "abc" })]); } #[test] fn native_command_names_are_routed_before_plugin_commands() { let colliding_plugin = plugin_slash_command( "dev.acme.plugin", "dev.acme.plugin.clean", "/clean", "Plugin clean", false, SlashCommandAvailability::Available, ) .unwrap(); let sid = session(); let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) .execute_with_plugins( ExecuteSlashCommandInput { name: "/clean".to_owned(), session_id: Some(sid), arguments: Vec::new(), }, vec![colliding_plugin], ) .unwrap(); assert_eq!(out.command.source, SlashCommandSource::Native); assert_eq!( out.effect, SlashCommandEffect::CleanConversation { session_id: sid } ); } #[test] fn runtime_catalog_plugin_slash_commands_feed_unified_registry() { let catalog = PluginRuntimeCatalog { plugins: vec![crate::PluginRuntimePlugin { id: "dev.acme.plugin".to_owned(), display_name: "Acme".to_owned(), publisher: None, version: "1.0.0".to_owned(), bundle_url: "idea-plugin://dev.acme.plugin/1.0.0/hash/dist/index.js".to_owned(), plugin_root: "/plugins/installed/dev.acme.plugin".to_owned(), icon_url: None, content_hash: "abc".to_owned(), capabilities: vec![domain::PluginCapability::Ui], activation_scope: domain::PluginActivationScope::App, contributes: domain::PluginContributionSet { slash_commands: vec![domain::PluginSlashCommandContribution { name: "/explain".to_owned(), short_description: "Explain selection".to_owned(), command: domain::PluginCommandId::new("dev.acme.plugin.explain").unwrap(), requires_confirmation: true, when: None, }], ..domain::PluginContributionSet::default() }, }], }; let commands = plugin_slash_commands_from_runtime_catalog(&catalog).unwrap(); let listed = ListSlashCommands::new(SlashCommandRegistry::new()).execute_with_plugins( ListSlashCommandsInput { prefix: Some("exp".to_owned()), }, commands, ); assert_eq!(listed.commands.len(), 1); assert_eq!(listed.commands[0].name, "/explain"); assert_eq!( listed.commands[0].plugin.as_ref().unwrap().command_id, "dev.acme.plugin.explain" ); assert!(listed.commands[0].requires_confirmation); } }