//! Slash-command model for the custom structured CLI surface. //! //! This module is intentionally pure: it declares the stable command metadata and //! validates names/prefixes, but it does not execute side effects. use serde::{Deserialize, Serialize}; use crate::error::DomainError; /// A slash-command source. Native commands are built into IdeA; plugin commands /// will be contributed through the same registry contract later. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SlashCommandSource { /// Built into IdeA. Native, /// Contributed by a plugin. Plugin { /// Opaque plugin id. plugin_id: String, }, } /// Whether a command can currently be executed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "status")] pub enum SlashCommandAvailability { /// The command can be executed now. Available, /// The command is known but cannot be executed in the current product state. Unavailable { /// Human-readable reason. reason: String, }, } impl SlashCommandAvailability { /// Returns whether this availability allows execution. #[must_use] pub const fn is_available(&self) -> bool { matches!(self, Self::Available) } } /// Built-in command behaviour known to IdeA. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NativeSlashCommand { /// Lists commands and help text. Help, /// Clears the current structured conversation view. Clean, /// Opens the profile switch flow. Implemented by ticket #164. Profile, } /// UI-facing slash-command metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SlashCommand { /// Canonical slash name, including the leading `/`. pub name: String, /// Short UI label/description. pub short_description: String, /// Whether the UI must ask for confirmation before execution. pub requires_confirmation: bool, /// Current command availability. pub availability: SlashCommandAvailability, /// Unified native/plugin source. pub source: SlashCommandSource, /// Built-in command discriminator when `source == native`. #[serde(default, skip_serializing_if = "Option::is_none")] pub native: Option, } impl SlashCommand { /// Builds validated command metadata. /// /// # Errors /// Returns [`DomainError::EmptyField`] for an empty/blank name or description, /// and [`DomainError::Invariant`] when the name does not start with `/`. pub fn new( name: impl Into, short_description: impl Into, requires_confirmation: bool, availability: SlashCommandAvailability, source: SlashCommandSource, native: Option, ) -> Result { let name = name.into(); let short_description = short_description.into(); crate::validation::non_empty(&name, "slash_command.name")?; crate::validation::non_empty(&short_description, "slash_command.short_description")?; if !name.starts_with('/') { return Err(DomainError::Invariant( "slash command name must start with '/'".to_owned(), )); } Ok(Self { name, short_description, requires_confirmation, availability, source, native, }) } } /// Returns the native IdeA slash commands in deterministic UI order. #[must_use] pub fn native_slash_commands() -> Vec { vec![ SlashCommand::new( "/help", "Afficher les commandes disponibles", false, SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Help), ) .expect("valid native slash command"), SlashCommand::new( "/clean", "Nettoyer la conversation courante", false, SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Clean), ) .expect("valid native slash command"), SlashCommand::new( "/profile", "Changer le profil de l'agent", true, SlashCommandAvailability::Available, SlashCommandSource::Native, Some(NativeSlashCommand::Profile), ) .expect("valid native slash command"), ] } /// Filters commands by a user-entered slash prefix. /// /// Blank prefixes return every command. Prefixes without a leading `/` are /// interpreted as if the slash was present, matching composer input. #[must_use] pub fn filter_slash_commands( commands: impl IntoIterator, prefix: Option<&str>, ) -> Vec { let prefix = prefix.map(str::trim).filter(|p| !p.is_empty()).map(|p| { if p.starts_with('/') { p.to_owned() } else { format!("/{p}") } }); commands .into_iter() .filter(|command| { prefix .as_ref() .is_none_or(|prefix| command.name.starts_with(prefix)) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn native_registry_exposes_help_clean_and_profile_placeholder() { let commands = native_slash_commands(); let names = commands.iter().map(|c| c.name.as_str()).collect::>(); assert_eq!(names, vec!["/help", "/clean", "/profile"]); assert!(commands[0].availability.is_available()); assert!(commands[1].availability.is_available()); assert!(commands[2].availability.is_available()); assert!(commands[2].requires_confirmation); } #[test] fn prefix_filter_accepts_with_or_without_leading_slash() { let names = filter_slash_commands(native_slash_commands(), Some("cl")) .into_iter() .map(|c| c.name) .collect::>(); assert_eq!(names, vec!["/clean"]); let names = filter_slash_commands(native_slash_commands(), Some("/pro")) .into_iter() .map(|c| c.name) .collect::>(); assert_eq!(names, vec!["/profile"]); } }