Permet à un plugin de contribuer des commandes slash adossées à une callback, transitant par le registry/contrat unifié (#162) : - sdk/IdeaSDK: manifeste contributes.slashCommands (déclaratif) — pointer bumpé. - domain: source Plugin étendue + effet PluginCallback (la commande ne s'exécute pas directement : elle renvoie une référence à la callback du plugin). - application: registration des commandes plugin dans le registry + exécution renvoyant l'effet PluginCallback ; métadonnées UI suffisantes pour l'autocomplete. - backend + app-tauri: DTO + commandes Tauri listant/invoquant les commandes plugin. - frontend (contrat): types adapters/domain/ports/mock pour pluginCallback et l'invocation avec arguments. L'exécution effective de la callback côté runtime UI est livrée par #166. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269 lines
8.5 KiB
Rust
269 lines
8.5 KiB
Rust
//! 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.
|
|
#[serde(rename = "pluginId")]
|
|
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,
|
|
}
|
|
|
|
/// Plugin callback identity for a contributed slash command.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginSlashCommandCallback {
|
|
/// Opaque plugin id.
|
|
pub plugin_id: String,
|
|
/// SDK command callback id registered by the plugin.
|
|
pub command_id: String,
|
|
}
|
|
|
|
/// 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<NativeSlashCommand>,
|
|
/// Plugin callback identity when `source == plugin`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub plugin: Option<PluginSlashCommandCallback>,
|
|
}
|
|
|
|
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<String>,
|
|
short_description: impl Into<String>,
|
|
requires_confirmation: bool,
|
|
availability: SlashCommandAvailability,
|
|
source: SlashCommandSource,
|
|
native: Option<NativeSlashCommand>,
|
|
plugin: Option<PluginSlashCommandCallback>,
|
|
) -> Result<Self, DomainError> {
|
|
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,
|
|
plugin,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Returns the native IdeA slash commands in deterministic UI order.
|
|
#[must_use]
|
|
pub fn native_slash_commands() -> Vec<SlashCommand> {
|
|
vec![
|
|
SlashCommand::new(
|
|
"/help",
|
|
"Afficher les commandes disponibles",
|
|
false,
|
|
SlashCommandAvailability::Available,
|
|
SlashCommandSource::Native,
|
|
Some(NativeSlashCommand::Help),
|
|
None,
|
|
)
|
|
.expect("valid native slash command"),
|
|
SlashCommand::new(
|
|
"/clean",
|
|
"Nettoyer la conversation courante",
|
|
false,
|
|
SlashCommandAvailability::Available,
|
|
SlashCommandSource::Native,
|
|
Some(NativeSlashCommand::Clean),
|
|
None,
|
|
)
|
|
.expect("valid native slash command"),
|
|
SlashCommand::new(
|
|
"/profile",
|
|
"Changer le profil de l'agent",
|
|
true,
|
|
SlashCommandAvailability::Available,
|
|
SlashCommandSource::Native,
|
|
Some(NativeSlashCommand::Profile),
|
|
None,
|
|
)
|
|
.expect("valid native slash command"),
|
|
]
|
|
}
|
|
|
|
/// Builds validated metadata for a plugin-contributed slash command.
|
|
pub fn plugin_slash_command(
|
|
plugin_id: impl Into<String>,
|
|
command_id: impl Into<String>,
|
|
name: impl Into<String>,
|
|
short_description: impl Into<String>,
|
|
requires_confirmation: bool,
|
|
availability: SlashCommandAvailability,
|
|
) -> Result<SlashCommand, DomainError> {
|
|
let plugin_id = plugin_id.into();
|
|
let command_id = command_id.into();
|
|
crate::validation::non_empty(&plugin_id, "slash_command.plugin_id")?;
|
|
crate::validation::non_empty(&command_id, "slash_command.command_id")?;
|
|
SlashCommand::new(
|
|
name,
|
|
short_description,
|
|
requires_confirmation,
|
|
availability,
|
|
SlashCommandSource::Plugin {
|
|
plugin_id: plugin_id.clone(),
|
|
},
|
|
None,
|
|
Some(PluginSlashCommandCallback {
|
|
plugin_id,
|
|
command_id,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// 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<Item = SlashCommand>,
|
|
prefix: Option<&str>,
|
|
) -> Vec<SlashCommand> {
|
|
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::<Vec<_>>();
|
|
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);
|
|
assert!(commands.iter().all(|c| c.plugin.is_none()));
|
|
}
|
|
|
|
#[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::<Vec<_>>();
|
|
assert_eq!(names, vec!["/clean"]);
|
|
|
|
let names = filter_slash_commands(native_slash_commands(), Some("/pro"))
|
|
.into_iter()
|
|
.map(|c| c.name)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(names, vec!["/profile"]);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_command_carries_callback_identity() {
|
|
let command = plugin_slash_command(
|
|
"dev.acme.plugin",
|
|
"dev.acme.plugin.explain",
|
|
"/explain",
|
|
"Explain selection",
|
|
true,
|
|
SlashCommandAvailability::Available,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(command.name, "/explain");
|
|
assert!(command.native.is_none());
|
|
assert_eq!(
|
|
command.plugin.unwrap().command_id,
|
|
"dev.acme.plugin.explain"
|
|
);
|
|
}
|
|
}
|