Files
IdeA/crates/domain/src/slash_command.rs
Blomios 2cd8df4a4e feat(cli): commande /profile + sélection de profil avec reset de session confirmé — #164 (QA verte)
Ajoute /profile comme commande slash first-class dont la sémantique de reset
de session reste explicite et testable, séparée de la palette générique :

- domain + application: /profile est enregistrée et renvoie un effet
  SlashCommandEffect::ProfileSwitch { session_id }. La commande ne reset pas
  directement : elle délègue l'effet au frontend pour imposer une confirmation.
- frontend (CustomAgentChatView): à l'exécution de /profile, ouverture d'un
  popup de sélection de profil informant que la validation reset la session,
  avec confirmation explicite (confirmedReset) requise avant changeAgentProfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:12:00 +02:00

200 lines
6.4 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.
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<NativeSlashCommand>,
}
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>,
) -> 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,
})
}
}
/// 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),
)
.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<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);
}
#[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"]);
}
}