//! Command engine: typed command enum, registry, and execution bridge. //! //! Maps every REPL slash command and lifecycle operation into a single `Command` //! enum with typed parameters. `CommandRegistry` parses raw input and delegates //! execution to the existing `cmd_*` handlers in `repl.rs`. use std::collections::HashMap; use quicprochat_proto::node_capnp::node_service; use super::repl::{Input, SlashCommand, parse_input}; use super::session::SessionState; // ── Comparison operator for assert conditions ──────────────────────────────── /// Comparison operator used in playbook assertions. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))] pub enum CmpOp { Eq, Ne, Gt, Lt, Gte, Lte, } impl CmpOp { /// Evaluate this comparison: `lhs rhs`. pub fn eval(&self, lhs: usize, rhs: usize) -> bool { match self { CmpOp::Eq => lhs == rhs, CmpOp::Ne => lhs != rhs, CmpOp::Gt => lhs > rhs, CmpOp::Lt => lhs < rhs, CmpOp::Gte => lhs >= rhs, CmpOp::Lte => lhs <= rhs, } } } // ── Assert conditions for playbook testing ─────────────────────────────────── /// Conditions that can be asserted in a playbook step. #[derive(Debug, Clone)] #[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))] pub enum AssertCondition { Connected, LoggedIn, InConversation { name: String }, MessageCount { op: CmpOp, count: usize }, MemberCount { op: CmpOp, count: usize }, Custom { expression: String }, } // ── Command enum ───────────────────────────────────────────────────────────── /// Every operation the client can perform, with typed parameters. /// /// This is a superset of `SlashCommand` — it adds lifecycle operations /// (`Connect`, `Login`, `Register`, `SendMessage`, `Wait`, `Assert`, `SetVar`) /// that are needed for non-interactive / playbook execution. #[derive(Debug, Clone)] #[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))] pub enum Command { // ── Lifecycle (not in SlashCommand) ────────────────────────────────── Connect { server: String, ca_cert: Option, insecure: bool, }, Login { username: String, password: String, }, Register { username: String, password: String, }, SendMessage { text: String, }, Wait { duration_ms: u64, }, Assert { condition: AssertCondition, }, SetVar { name: String, value: String, }, // ── SlashCommand mirror ───────────────────────────────────────────── Help, Quit, Whoami, List, Switch { target: String }, Dm { username: String }, CreateGroup { name: String }, Invite { target: String }, Remove { target: String }, Leave, Join, Members, GroupInfo, Rename { name: String }, History { count: usize }, // Mesh MeshStart, MeshStop, MeshPeers, MeshServer { addr: String }, MeshSend { peer_id: String, message: String }, MeshBroadcast { topic: String, message: String }, MeshSubscribe { topic: String }, MeshRoute, MeshIdentity, MeshStore, // Security / crypto Verify { username: String }, UpdateKey, Typing, TypingNotify { enabled: bool }, React { emoji: String, index: Option }, Edit { index: usize, new_text: String }, Delete { index: usize }, SendFile { path: String }, Download { index: usize }, DeleteAccount, Disappear { arg: Option }, Privacy { arg: Option }, VerifyFs, RotateAllKeys, Devices, RegisterDevice { name: String }, RevokeDevice { id_prefix: String }, } impl Command { /// Convert a `Command` to a `SlashCommand` when possible. /// /// Returns `None` for lifecycle commands that have no `SlashCommand` /// equivalent (`Connect`, `Login`, `Register`, `SendMessage`, `Wait`, /// `Assert`, `SetVar`). pub(crate) fn to_slash(&self) -> Option { match self.clone() { // Lifecycle — no SlashCommand equivalent Command::Connect { .. } | Command::Login { .. } | Command::Register { .. } | Command::SendMessage { .. } | Command::Wait { .. } | Command::Assert { .. } | Command::SetVar { .. } => None, // 1:1 mirror Command::Help => Some(SlashCommand::Help), Command::Quit => Some(SlashCommand::Quit), Command::Whoami => Some(SlashCommand::Whoami), Command::List => Some(SlashCommand::List), Command::Switch { target } => Some(SlashCommand::Switch { target }), Command::Dm { username } => Some(SlashCommand::Dm { username }), Command::CreateGroup { name } => Some(SlashCommand::CreateGroup { name }), Command::Invite { target } => Some(SlashCommand::Invite { target }), Command::Remove { target } => Some(SlashCommand::Remove { target }), Command::Leave => Some(SlashCommand::Leave), Command::Join => Some(SlashCommand::Join), Command::Members => Some(SlashCommand::Members), Command::GroupInfo => Some(SlashCommand::GroupInfo), Command::Rename { name } => Some(SlashCommand::Rename { name }), Command::History { count } => Some(SlashCommand::History { count }), Command::MeshStart => Some(SlashCommand::MeshStart), Command::MeshStop => Some(SlashCommand::MeshStop), Command::MeshPeers => Some(SlashCommand::MeshPeers), Command::MeshServer { addr } => Some(SlashCommand::MeshServer { addr }), Command::MeshSend { peer_id, message } => { Some(SlashCommand::MeshSend { peer_id, message }) } Command::MeshBroadcast { topic, message } => { Some(SlashCommand::MeshBroadcast { topic, message }) } Command::MeshSubscribe { topic } => Some(SlashCommand::MeshSubscribe { topic }), Command::MeshRoute => Some(SlashCommand::MeshRoute), Command::MeshIdentity => Some(SlashCommand::MeshIdentity), Command::MeshStore => Some(SlashCommand::MeshStore), Command::Verify { username } => Some(SlashCommand::Verify { username }), Command::UpdateKey => Some(SlashCommand::UpdateKey), Command::Typing => Some(SlashCommand::Typing), Command::TypingNotify { enabled } => Some(SlashCommand::TypingNotify { enabled }), Command::React { emoji, index } => Some(SlashCommand::React { emoji, index }), Command::Edit { index, new_text } => Some(SlashCommand::Edit { index, new_text }), Command::Delete { index } => Some(SlashCommand::Delete { index }), Command::SendFile { path } => Some(SlashCommand::SendFile { path }), Command::Download { index } => Some(SlashCommand::Download { index }), Command::DeleteAccount => Some(SlashCommand::DeleteAccount), Command::Disappear { arg } => Some(SlashCommand::Disappear { arg }), Command::Privacy { arg } => Some(SlashCommand::Privacy { arg }), Command::VerifyFs => Some(SlashCommand::VerifyFs), Command::RotateAllKeys => Some(SlashCommand::RotateAllKeys), Command::Devices => Some(SlashCommand::Devices), Command::RegisterDevice { name } => Some(SlashCommand::RegisterDevice { name }), Command::RevokeDevice { id_prefix } => { Some(SlashCommand::RevokeDevice { id_prefix }) } } } } // ── CommandResult ──────────────────────────────────────────────────────────── /// Outcome of executing a single `Command`. #[derive(Debug, Clone)] #[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))] pub struct CommandResult { pub success: bool, pub output: Option, pub error: Option, /// Structured key-value outputs for variable capture in playbooks. pub data: HashMap, } impl CommandResult { fn ok() -> Self { Self { success: true, output: None, error: None, data: HashMap::new(), } } fn err(msg: String) -> Self { Self { success: false, output: None, error: Some(msg), data: HashMap::new(), } } } // ── CommandRegistry ────────────────────────────────────────────────────────── /// Parses raw input into `Command` and delegates execution to the existing /// REPL handlers. pub struct CommandRegistry; impl CommandRegistry { /// Parse a raw input line into a `Command`. /// /// Returns `None` for empty input. Returns `Some(Command::SendMessage)` /// for plain chat text. Slash commands are parsed via the existing /// `parse_input` function. pub fn parse(line: &str) -> Option { match parse_input(line) { Input::Empty => None, Input::ChatMessage(text) => Some(Command::SendMessage { text }), Input::Slash(sc) => Some(slash_to_command(sc)), } } /// Execute a `Command`, delegating slash commands to the existing /// `handle_slash` dispatch and handling lifecycle commands directly. /// /// Currently, output from `cmd_*` handlers goes to stdout (unchanged). /// `CommandResult` captures success/failure status; stdout capture can /// be added later. pub async fn execute( cmd: &Command, session: &mut SessionState, client: &node_service::Client, ) -> CommandResult { match cmd { Command::Wait { duration_ms } => { tokio::time::sleep(std::time::Duration::from_millis(*duration_ms)).await; CommandResult::ok() } Command::SetVar { name, value } => { let mut result = CommandResult::ok(); result.data.insert(name.clone(), value.clone()); result } Command::Assert { condition } => execute_assert(condition, session), Command::Connect { .. } | Command::Login { .. } | Command::Register { .. } => { // These lifecycle commands require external context (endpoint, // OPAQUE state) that lives outside SessionState. The playbook // executor will handle them directly; calling execute() for // them is an error. CommandResult::err( "lifecycle commands (connect/login/register) must be handled by the playbook executor".into(), ) } Command::SendMessage { text } => { match super::repl::do_send(session, client, text).await { Ok(()) => CommandResult::ok(), Err(e) => CommandResult::err(format!("{e:#}")), } } Command::Quit => CommandResult::ok(), other => { // All remaining variants have a SlashCommand equivalent. if let Some(sc) = other.to_slash() { match execute_slash(session, client, sc).await { Ok(()) => CommandResult::ok(), Err(e) => CommandResult::err(format!("{e:#}")), } } else { CommandResult::err("command has no slash equivalent".into()) } } } } } // ── Conversion helpers ────────────────────────────────────────────────────── /// Convert a `SlashCommand` into the corresponding `Command`. fn slash_to_command(sc: SlashCommand) -> Command { match sc { SlashCommand::Help => Command::Help, SlashCommand::Quit => Command::Quit, SlashCommand::Whoami => Command::Whoami, SlashCommand::List => Command::List, SlashCommand::Switch { target } => Command::Switch { target }, SlashCommand::Dm { username } => Command::Dm { username }, SlashCommand::CreateGroup { name } => Command::CreateGroup { name }, SlashCommand::Invite { target } => Command::Invite { target }, SlashCommand::Remove { target } => Command::Remove { target }, SlashCommand::Leave => Command::Leave, SlashCommand::Join => Command::Join, SlashCommand::Members => Command::Members, SlashCommand::GroupInfo => Command::GroupInfo, SlashCommand::Rename { name } => Command::Rename { name }, SlashCommand::History { count } => Command::History { count }, SlashCommand::MeshStart => Command::MeshStart, SlashCommand::MeshStop => Command::MeshStop, SlashCommand::MeshPeers => Command::MeshPeers, SlashCommand::MeshServer { addr } => Command::MeshServer { addr }, SlashCommand::MeshSend { peer_id, message } => Command::MeshSend { peer_id, message }, SlashCommand::MeshBroadcast { topic, message } => { Command::MeshBroadcast { topic, message } } SlashCommand::MeshSubscribe { topic } => Command::MeshSubscribe { topic }, SlashCommand::MeshRoute => Command::MeshRoute, SlashCommand::MeshIdentity => Command::MeshIdentity, SlashCommand::MeshStore => Command::MeshStore, SlashCommand::Verify { username } => Command::Verify { username }, SlashCommand::UpdateKey => Command::UpdateKey, SlashCommand::Typing => Command::Typing, SlashCommand::TypingNotify { enabled } => Command::TypingNotify { enabled }, SlashCommand::React { emoji, index } => Command::React { emoji, index }, SlashCommand::Edit { index, new_text } => Command::Edit { index, new_text }, SlashCommand::Delete { index } => Command::Delete { index }, SlashCommand::SendFile { path } => Command::SendFile { path }, SlashCommand::Download { index } => Command::Download { index }, SlashCommand::DeleteAccount => Command::DeleteAccount, SlashCommand::Disappear { arg } => Command::Disappear { arg }, SlashCommand::Privacy { arg } => Command::Privacy { arg }, SlashCommand::VerifyFs => Command::VerifyFs, SlashCommand::RotateAllKeys => Command::RotateAllKeys, SlashCommand::Devices => Command::Devices, SlashCommand::RegisterDevice { name } => Command::RegisterDevice { name }, SlashCommand::RevokeDevice { id_prefix } => Command::RevokeDevice { id_prefix }, } } // ── Execution helpers ─────────────────────────────────────────────────────── /// Execute a `SlashCommand` using the existing `cmd_*` handlers from `repl.rs`. /// /// This duplicates the dispatch table from `handle_slash` but returns /// `anyhow::Result<()>` instead of printing errors inline — the caller /// decides how to surface errors. async fn execute_slash( session: &mut SessionState, client: &node_service::Client, cmd: SlashCommand, ) -> anyhow::Result<()> { use super::repl::*; match cmd { SlashCommand::Help => { print_help(); Ok(()) } SlashCommand::Quit => Ok(()), SlashCommand::Whoami => cmd_whoami(session), SlashCommand::List => cmd_list(session), SlashCommand::Switch { target } => cmd_switch(session, &target), SlashCommand::Dm { username } => cmd_dm(session, client, &username).await, SlashCommand::CreateGroup { name } => cmd_create_group(session, &name), SlashCommand::Invite { target } => cmd_invite(session, client, &target).await, SlashCommand::Remove { target } => cmd_remove(session, client, &target).await, SlashCommand::Leave => cmd_leave(session, client).await, SlashCommand::Join => cmd_join(session, client).await, SlashCommand::Members => cmd_members(session, client).await, SlashCommand::GroupInfo => cmd_group_info(session, client).await, SlashCommand::Rename { name } => cmd_rename(session, &name), SlashCommand::History { count } => cmd_history(session, count), SlashCommand::MeshStart => cmd_mesh_start(session).await, SlashCommand::MeshStop => cmd_mesh_stop(session).await, SlashCommand::MeshPeers => cmd_mesh_peers(), SlashCommand::MeshServer { addr } => { super::display::print_status(&format!( "mesh server hint: reconnect with --server {addr} to use this node" )); Ok(()) } SlashCommand::MeshSend { peer_id, message } => cmd_mesh_send(session, &peer_id, &message).await, SlashCommand::MeshBroadcast { topic, message } => cmd_mesh_broadcast(session, &topic, &message).await, SlashCommand::MeshSubscribe { topic } => cmd_mesh_subscribe(session, &topic), SlashCommand::MeshRoute => cmd_mesh_route(session), SlashCommand::MeshIdentity => cmd_mesh_identity(session), SlashCommand::MeshStore => cmd_mesh_store(session), SlashCommand::Verify { username } => cmd_verify(session, client, &username).await, SlashCommand::UpdateKey => cmd_update_key(session, client).await, SlashCommand::Typing => cmd_typing(session, client).await, SlashCommand::TypingNotify { enabled } => { session.typing_notify_enabled = enabled; super::display::print_status(&format!( "typing notifications {}", if enabled { "enabled" } else { "disabled" } )); Ok(()) } SlashCommand::React { emoji, index } => cmd_react(session, client, &emoji, index).await, SlashCommand::Edit { index, new_text } => { cmd_edit(session, client, index, &new_text).await } SlashCommand::Delete { index } => cmd_delete(session, client, index).await, SlashCommand::SendFile { path } => cmd_send_file(session, client, &path).await, SlashCommand::Download { index } => cmd_download(session, client, index).await, SlashCommand::DeleteAccount => cmd_delete_account(session, client).await, SlashCommand::Disappear { arg } => cmd_disappear(session, arg.as_deref()), SlashCommand::Privacy { arg } => cmd_privacy(session, arg.as_deref()), SlashCommand::VerifyFs => cmd_verify_fs(session), SlashCommand::RotateAllKeys => cmd_rotate_all_keys(session, client).await, SlashCommand::Devices => cmd_devices(client).await, SlashCommand::RegisterDevice { name } => cmd_register_device(client, &name).await, SlashCommand::RevokeDevice { id_prefix } => cmd_revoke_device(client, &id_prefix).await, } } /// Assert a condition against the current session state. fn execute_assert(condition: &AssertCondition, session: &SessionState) -> CommandResult { match condition { AssertCondition::Connected => { // We have a session => we got past connect. Always true when // execute() is called with a valid client reference. CommandResult::ok() } AssertCondition::LoggedIn => { let guard = crate::AUTH_CONTEXT .read() .expect("AUTH_CONTEXT poisoned"); if guard.is_some() { CommandResult::ok() } else { CommandResult::err("not logged in".into()) } } AssertCondition::InConversation { name } => { if let Some(display) = session.active_display_name() { if display.contains(name.as_str()) { CommandResult::ok() } else { CommandResult::err(format!( "active conversation is '{display}', expected '{name}'" )) } } else { CommandResult::err("no active conversation".into()) } } AssertCondition::MessageCount { op, count } => { let actual = session .active_conversation .as_ref() .and_then(|id| session.conv_store.load_all_messages(id).ok()) .map(|msgs| msgs.len()) .unwrap_or(0); if op.eval(actual, *count) { CommandResult::ok() } else { CommandResult::err(format!( "message count assertion failed: {actual} {op:?} {count}" )) } } AssertCondition::MemberCount { op, count } => { let actual = session .active_conversation .as_ref() .and_then(|id| session.members.get(id)) .map(|m| m.member_identities().len()) .unwrap_or(0); if op.eval(actual, *count) { CommandResult::ok() } else { CommandResult::err(format!( "member count assertion failed: {actual} {op:?} {count}" )) } } AssertCondition::Custom { expression } => { // Custom expressions are not evaluated yet; always pass. let mut result = CommandResult::ok(); result.data.insert("expression".into(), expression.clone()); result } } }