feat(slash): fondation du système de commandes slash pour la CLI custom — #162 (QA verte)
Introduit le contrat unifié des commandes slash consommable par le frontend, indépendant de la source (native, puis plugin plus tard) : - domain: modèle pur SlashCommand / SlashCommandSource / SlashCommandAvailability, métadonnées UI (nom, description, disponibilité, confirmation requise) et validation des noms/préfixes. - application: registry + list/filter par préfixe + plan d'exécution natif ; commandes natives /help et /clean (/clean = nettoyage de la vue de session courante ; pas de /reset distinct tant qu'aucune utilité produit ne le justifie). - backend + app-tauri: exposition du contrat via DTO/transport + tests DTO. Source neutre : les commandes contribuées par plugins (#165) transiteront par le même registry sans changer le contrat frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -32,6 +32,7 @@ pub mod plugin;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod slash_command;
|
||||
pub mod sprints;
|
||||
pub mod system_permissions;
|
||||
pub mod template;
|
||||
@ -194,6 +195,10 @@ pub use skill::{
|
||||
ResolveAgentCapabilitiesInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput,
|
||||
UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
||||
};
|
||||
pub use slash_command::{
|
||||
ExecuteSlashCommand, ExecuteSlashCommandInput, ExecuteSlashCommandOutput, ListSlashCommands,
|
||||
ListSlashCommandsInput, ListSlashCommandsOutput, SlashCommandEffect, SlashCommandRegistry,
|
||||
};
|
||||
pub use sprints::{
|
||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
||||
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,
|
||||
|
||||
256
crates/application/src/slash_command.rs
Normal file
256
crates/application/src/slash_command.rs
Normal file
@ -0,0 +1,256 @@
|
||||
//! 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, NativeSlashCommand, SessionId, SlashCommand,
|
||||
SlashCommandAvailability,
|
||||
};
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
/// Lists slash commands.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ListSlashCommandsInput {
|
||||
/// Optional typed prefix, with or without the leading `/`.
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Slash-command list output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSlashCommandsOutput {
|
||||
/// Filtered commands in deterministic UI order.
|
||||
pub commands: Vec<SlashCommand>,
|
||||
}
|
||||
|
||||
/// 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<SessionId>,
|
||||
}
|
||||
|
||||
/// 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<SlashCommand>,
|
||||
},
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
/// In-memory slash-command registry.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SlashCommandRegistry {
|
||||
plugin_commands: Vec<SlashCommand>,
|
||||
}
|
||||
|
||||
impl SlashCommandRegistry {
|
||||
/// Creates an empty registry with IdeA native commands.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
plugin_commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all commands from all sources in deterministic order.
|
||||
#[must_use]
|
||||
pub fn all(&self) -> Vec<SlashCommand> {
|
||||
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<ExecuteSlashCommandOutput, AppError> {
|
||||
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}"
|
||||
)));
|
||||
}
|
||||
|
||||
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<String, AppError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ExecuteSlashCommandOutput, AppError> {
|
||||
self.registry.execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
})
|
||||
.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),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
SlashCommandEffect::CleanConversation { session_id: sid }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_is_known_but_not_executable_yet() {
|
||||
let err = ExecuteSlashCommand::new(SlashCommandRegistry::new())
|
||||
.execute(ExecuteSlashCommandInput {
|
||||
name: "/profile".to_owned(),
|
||||
session_id: Some(session()),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Invalid(message) if message.contains("unavailable")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user