use client::ticket_role::TicketRole; use protocol::Method; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandInputMode { Composer, Command, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandArgs { raw: String, argv: Vec, } impl CommandArgs { pub fn parse_whitespace(raw: &str) -> Self { Self { raw: raw.to_owned(), argv: raw.split_whitespace().map(str::to_owned).collect(), } } pub fn raw(&self) -> &str { &self.raw } pub fn argv(&self) -> &[String] { &self.argv } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandDiagnostic { pub message: String, } impl CommandDiagnostic { pub fn new(message: impl Into) -> Self { Self { message: message.into(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandEnvironment { pub connected: bool, pub running: bool, pub paused: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandAction { TicketRole(TicketRoleCommand), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRoleCommand { pub role: TicketRole, pub ticket: Option, pub instruction: Option, } #[derive(Debug, Clone)] pub struct CommandExecution { pub method: Option, pub action: Option, pub diagnostics: Vec, pub exit_command_mode: bool, pub clear_input: bool, } impl CommandExecution { pub fn diagnostic(message: impl Into) -> Self { Self { method: None, action: None, diagnostics: vec![CommandDiagnostic::new(message)], exit_command_mode: false, clear_input: false, } } pub fn notice(message: impl Into) -> Self { Self { method: None, action: None, diagnostics: vec![CommandDiagnostic::new(message)], exit_command_mode: true, clear_input: true, } } pub fn local_action(message: impl Into, action: CommandAction) -> Self { Self { method: None, action: Some(action), diagnostics: vec![CommandDiagnostic::new(message)], exit_command_mode: true, clear_input: true, } } } pub type ArgumentParser = fn(&str) -> Result; pub type AvailabilityCheck = fn(&CommandEnvironment) -> Result<(), CommandDiagnostic>; pub type CommandExecutor = fn(CommandInvocation<'_>) -> CommandExecution; #[derive(Clone)] pub struct CommandSpec { pub name: &'static str, pub aliases: &'static [&'static str], pub usage: &'static str, pub description: &'static str, pub argument_parser: ArgumentParser, pub can_execute: AvailabilityCheck, pub executor: CommandExecutor, } pub struct CommandInvocation<'a> { pub registry: &'a CommandRegistry, pub command: &'a CommandSpec, pub args: CommandArgs, pub environment: &'a CommandEnvironment, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandCandidate { pub name: &'static str, pub usage: &'static str, pub description: &'static str, } #[derive(Clone)] pub struct CommandRegistry { commands: Vec, } impl CommandRegistry { pub fn new() -> Self { Self { commands: Vec::new(), } } pub fn builtins() -> Self { let mut registry = Self::new(); registry.register(CommandSpec { name: "help", aliases: &["?"], usage: "help [command]", description: "Show available TUI commands or details for one command.", argument_parser: help_args, can_execute: always_available, executor: help_command, }); registry.register(CommandSpec { name: "noop", aliases: &["nop"], usage: "noop", description: "Validate command dispatch without side effects.", argument_parser: no_args, can_execute: always_available, executor: noop_command, }); registry.register(CommandSpec { name: "compact", aliases: &[], usage: "compact", description: "Request immediate Pod context compaction.", argument_parser: compact_args, can_execute: compact_available, executor: compact_command, }); registry.register(CommandSpec { name: "rewind", aliases: &["rollback"], usage: "rewind", description: "Open the rewind target picker.", argument_parser: rewind_args, can_execute: rewind_available, executor: rewind_command, }); registry.register(CommandSpec { name: "peer", aliases: &[], usage: "peer ", description: "Register another existing Pod as a reciprocal metadata peer.", argument_parser: peer_args, can_execute: peer_available, executor: peer_command, }); registry.register(CommandSpec { name: "ticket", aliases: &[], usage: "ticket ...", description: "Launch a fixed Ticket role Pod using .yoi/ticket.config.toml.", argument_parser: ticket_args, can_execute: always_available, executor: ticket_command, }); registry } pub fn register(&mut self, spec: CommandSpec) { debug_assert!(!self.commands.iter().any(|c| c.name == spec.name)); self.commands.push(spec); } pub fn commands(&self) -> &[CommandSpec] { &self.commands } pub fn find(&self, name_or_alias: &str) -> Option<&CommandSpec> { self.commands.iter().find(|command| { command.name == name_or_alias || command.aliases.iter().any(|alias| *alias == name_or_alias) }) } pub fn suggest(&self, command_line: &str) -> Vec { let prefix = command_prefix(command_line); if prefix.is_empty() { return self.commands.iter().map(CommandCandidate::from).collect(); } self.commands .iter() .filter(|command| { command.name.starts_with(prefix) || command .aliases .iter() .any(|alias| alias.starts_with(prefix)) }) .map(CommandCandidate::from) .collect() } pub fn dispatch( &self, command_line: &str, environment: &CommandEnvironment, ) -> CommandExecution { let trimmed = command_line.trim(); if trimmed.is_empty() { return CommandExecution::diagnostic( "Empty command. Type :help for available commands.", ); } let (name, raw_args) = split_command(trimmed); let Some(command) = self.find(name) else { return CommandExecution::diagnostic(format!( "Unknown command: {name}. Type :help for available commands." )); }; let args = match (command.argument_parser)(raw_args) { Ok(args) => args, Err(err) => return CommandExecution::diagnostic(err.message), }; if let Err(err) = (command.can_execute)(environment) { return CommandExecution::diagnostic(err.message); } (command.executor)(CommandInvocation { registry: self, command, args, environment, }) } } impl Default for CommandRegistry { fn default() -> Self { Self::builtins() } } impl From<&CommandSpec> for CommandCandidate { fn from(command: &CommandSpec) -> Self { Self { name: command.name, usage: command.usage, description: command.description, } } } fn split_command(trimmed: &str) -> (&str, &str) { match trimmed.find(char::is_whitespace) { Some(idx) => { let (name, rest) = trimmed.split_at(idx); (name, rest.trim_start()) } None => (trimmed, ""), } } fn command_prefix(command_line: &str) -> &str { let trimmed = command_line.trim_start(); match trimmed.find(char::is_whitespace) { Some(idx) => &trimmed[..idx], None => trimmed, } } fn always_available(_environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { Ok(()) } fn no_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); if args.argv().is_empty() { Ok(args) } else { Err(CommandDiagnostic::new("Invalid arguments. Usage: noop")) } } fn help_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); if args.argv().len() <= 1 { Ok(args) } else { Err(CommandDiagnostic::new( "Invalid arguments. Usage: help [command]", )) } } fn compact_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); if args.argv().is_empty() { Ok(args) } else { Err(CommandDiagnostic::new("Invalid arguments. Usage: compact")) } } fn rewind_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); if args.argv().is_empty() { Ok(args) } else { Err(CommandDiagnostic::new("Invalid arguments. Usage: rewind")) } } fn peer_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); if args.argv().len() == 1 { Ok(args) } else { Err(CommandDiagnostic::new( "Invalid arguments. Usage: peer ", )) } } fn ticket_args(raw: &str) -> Result { let args = CommandArgs::parse_whitespace(raw); let Some(action) = args.argv().first().map(String::as_str) else { return Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket ...", )); }; match action { "intake" if args.argv().len() >= 2 => Ok(args), "intake" => Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket intake ", )), "route" | "investigate" | "implement" | "review" if args.argv().len() >= 2 => Ok(args), "route" => Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket route [instruction...]", )), "investigate" => Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket investigate [instruction...]", )), "implement" => Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket implement [instruction...]", )), "review" => Err(CommandDiagnostic::new( "Invalid arguments. Usage: ticket review [instruction...]", )), _ => Err(CommandDiagnostic::new(format!( "Unknown ticket action: {action}. Usage: ticket ..." ))), } } fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { if !environment.connected { return Err(CommandDiagnostic::new( "Cannot compact: not connected to a Pod.", )); } if environment.running { return Err(CommandDiagnostic::new( "Cannot compact while the Pod is running.", )); } if environment.paused { return Err(CommandDiagnostic::new( "Cannot compact while the Pod is paused; resume or start a fresh turn first.", )); } Ok(()) } fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { if !environment.connected { return Err(CommandDiagnostic::new( "Cannot rewind before the Pod is connected.", )); } if environment.running { return Err(CommandDiagnostic::new( "Cannot rewind while the Pod is running.", )); } Ok(()) } fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { if !environment.connected { return Err(CommandDiagnostic::new( "Cannot register a peer before the Pod is connected.", )); } if environment.running { return Err(CommandDiagnostic::new( "Cannot register a peer while the Pod is running.", )); } Ok(()) } fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution { if let Some(name) = invocation.args.argv().first() { let Some(command) = invocation.registry.find(name) else { return CommandExecution::diagnostic(format!( "Unknown command: {name}. Type :help for available commands." )); }; let aliases = if command.aliases.is_empty() { "".to_owned() } else { format!(" aliases: {}.", command.aliases.join(", ")) }; return CommandExecution::notice(format!( "command: {} — usage: {}.{} {}", command.name, command.usage, aliases, command.description )); } let list = invocation .registry .commands() .iter() .map(|command| format!("{} ({})", command.name, command.usage)) .collect::>() .join(", "); CommandExecution::notice(format!("available commands: {list}")) } fn noop_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.command; let _ = invocation.environment; let _ = invocation.args.raw(); CommandExecution::notice("noop: no action") } fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.command; let _ = invocation.environment; let _ = invocation.args.raw(); CommandExecution { method: Some(Method::Compact), action: None, diagnostics: vec![CommandDiagnostic::new("compact requested")], exit_command_mode: true, clear_input: true, } } fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.command; let _ = invocation.environment; let _ = invocation.args.raw(); CommandExecution { method: Some(Method::ListRewindTargets), action: None, diagnostics: vec![CommandDiagnostic::new("rewind picker requested")], exit_command_mode: true, clear_input: true, } } fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.command; let _ = invocation.environment; let name = invocation.args.argv()[0].clone(); CommandExecution { method: Some(Method::RegisterPeer { name: name.clone() }), action: None, diagnostics: vec![CommandDiagnostic::new(format!( "peer metadata registration requested with `{name}`" ))], exit_command_mode: true, clear_input: true, } } fn ticket_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.command; let _ = invocation.environment; let Some((action, rest)) = split_first_word(invocation.args.raw()) else { return CommandExecution::diagnostic( "Invalid arguments. Usage: ticket ...", ); }; let Some(role) = ticket_role_for_action(action) else { return CommandExecution::diagnostic(format!( "Unknown ticket action: {action}. Usage: ticket ..." )); }; let (ticket, instruction) = if action == "intake" { let Some(instruction) = non_empty_string(rest) else { return CommandExecution::diagnostic( "Invalid arguments. Usage: ticket intake ", ); }; (None, Some(instruction)) } else { let Some((ticket, rest)) = split_first_word(rest) else { return CommandExecution::diagnostic(format!( "Invalid arguments. Usage: ticket {action} [instruction...]" )); }; (Some(ticket.to_owned()), non_empty_string(rest)) }; CommandExecution::local_action( format!("ticket {action} launch requested"), CommandAction::TicketRole(TicketRoleCommand { role, ticket, instruction, }), ) } fn ticket_role_for_action(action: &str) -> Option { match action { "intake" => Some(TicketRole::Intake), "route" => Some(TicketRole::Orchestrator), "investigate" => Some(TicketRole::Investigator), "implement" => Some(TicketRole::Coder), "review" => Some(TicketRole::Reviewer), _ => None, } } fn split_first_word(raw: &str) -> Option<(&str, &str)> { let trimmed = raw.trim_start(); if trimmed.is_empty() { return None; } match trimmed.find(char::is_whitespace) { Some(idx) => { let (word, rest) = trimmed.split_at(idx); Some((word, rest.trim_start())) } None => Some((trimmed, "")), } } fn non_empty_string(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } } #[cfg(test)] mod tests { use super::*; fn env() -> CommandEnvironment { CommandEnvironment { connected: true, running: false, paused: false, } } #[test] fn builtins_suggest_by_prefix() { let registry = CommandRegistry::builtins(); assert_eq!(registry.suggest("he")[0].name, "help"); assert_eq!(registry.suggest("n")[0].name, "noop"); } #[test] fn unknown_command_is_local_diagnostic() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("wat", &env()); assert!(result.method.is_none()); assert!(!result.exit_command_mode); assert!(result.diagnostics[0].message.contains("Unknown command")); } #[test] fn invalid_arguments_are_local_diagnostic() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("noop extra", &env()); assert!(result.method.is_none()); assert!(!result.exit_command_mode); assert!(result.diagnostics[0].message.contains("Invalid arguments")); } #[test] fn compact_command_returns_compact_method_not_run() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("compact", &env()); assert!(matches!(result.method, Some(Method::Compact))); assert!(result.exit_command_mode); assert!(result.clear_input); assert!(result.diagnostics[0].message.contains("compact requested")); } #[test] fn compact_invalid_arguments_are_local_diagnostic() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("compact now", &env()); assert!(result.method.is_none()); assert!(!result.exit_command_mode); assert!(result.diagnostics[0].message.contains("Invalid arguments")); } #[test] fn compact_rejects_running_and_paused_locally() { let registry = CommandRegistry::builtins(); let mut running = env(); running.running = true; let result = registry.dispatch("compact", &running); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("running")); let mut paused = env(); paused.paused = true; let result = registry.dispatch("compact", &paused); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("paused")); } #[test] fn rewind_command_and_alias_return_list_method() { let registry = CommandRegistry::builtins(); for command in ["rewind", "rollback"] { let result = registry.dispatch(command, &env()); assert!(matches!(result.method, Some(Method::ListRewindTargets))); assert!(result.exit_command_mode); assert!(result.clear_input); assert!(result.diagnostics[0].message.contains("rewind picker")); } } #[test] fn rewind_invalid_arguments_are_local_diagnostic() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("rewind now", &env()); assert!(result.method.is_none()); assert!(!result.exit_command_mode); assert!(result.diagnostics[0].message.contains("Invalid arguments")); } #[test] fn rewind_rejects_running_but_allows_paused() { let registry = CommandRegistry::builtins(); let mut running = env(); running.running = true; let result = registry.dispatch("rewind", &running); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("running")); let mut paused = env(); paused.paused = true; let result = registry.dispatch("rewind", &paused); assert!(matches!(result.method, Some(Method::ListRewindTargets))); } #[test] fn peer_command_returns_register_peer_method() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("peer reviewer", &env()); assert!(matches!( result.method, Some(Method::RegisterPeer { ref name }) if name == "reviewer" )); assert!(result.exit_command_mode); assert!(result.clear_input); assert!( result.diagnostics[0] .message .contains("metadata registration") ); } #[test] fn peer_invalid_arguments_are_local_diagnostic() { let registry = CommandRegistry::builtins(); for command in ["peer", "peer one two"] { let result = registry.dispatch(command, &env()); assert!(result.method.is_none()); assert!(!result.exit_command_mode); assert!(result.diagnostics[0].message.contains("Invalid arguments")); } } #[test] fn ticket_intake_command_returns_local_ticket_action() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("ticket intake add role shortcuts", &env()); assert!(result.method.is_none()); assert!(result.exit_command_mode); assert!(result.clear_input); assert!(result.diagnostics[0].message.contains("ticket intake")); assert!(matches!( result.action, Some(CommandAction::TicketRole(TicketRoleCommand { role: TicketRole::Intake, ticket: None, instruction: Some(ref instruction), })) if instruction == "add role shortcuts" )); } #[test] fn ticket_intake_requires_context() { let registry = CommandRegistry::builtins(); for command in ["ticket intake", "ticket intake "] { let result = registry.dispatch(command, &env()); assert!(result.method.is_none()); assert!(result.action.is_none()); assert!(!result.exit_command_mode); assert_eq!( result.diagnostics[0].message, "Invalid arguments. Usage: ticket intake " ); } } #[test] fn ticket_role_commands_map_to_fixed_roles() { let registry = CommandRegistry::builtins(); for (command, role) in [ ("route", TicketRole::Orchestrator), ("investigate", TicketRole::Investigator), ("implement", TicketRole::Coder), ("review", TicketRole::Reviewer), ] { let result = registry.dispatch(&format!("ticket {command} abc-123 extra context"), &env()); assert!(result.method.is_none()); assert!(matches!( result.action, Some(CommandAction::TicketRole(TicketRoleCommand { role: actual_role, ticket: Some(ref ticket), instruction: Some(ref instruction), })) if actual_role == role && ticket == "abc-123" && instruction == "extra context" )); } } #[test] fn ticket_non_intake_requires_ticket_reference() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("ticket implement", &env()); assert!(result.method.is_none()); assert!(result.action.is_none()); assert!(!result.exit_command_mode); assert!( result.diagnostics[0] .message .contains("ticket implement ") ); } #[test] fn ticket_unknown_action_is_local_diagnostic() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("ticket close abc-123", &env()); assert!(result.method.is_none()); assert!(result.action.is_none()); assert!(!result.exit_command_mode); assert!( result.diagnostics[0] .message .contains("Unknown ticket action") ); } #[test] fn peer_help_mentions_metadata_registration() { let registry = CommandRegistry::builtins(); let result = registry.dispatch("help peer", &env()); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("peer ")); assert!(result.diagnostics[0].message.contains("metadata peer")); } #[test] fn peer_rejects_disconnected() { let registry = CommandRegistry::builtins(); let mut disconnected = env(); disconnected.connected = false; let result = registry.dispatch("peer reviewer", &disconnected); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("connected")); } #[test] fn peer_rejects_running() { let registry = CommandRegistry::builtins(); let mut running = env(); running.running = true; let result = registry.dispatch("peer reviewer", &running); assert!(result.method.is_none()); assert!(result.diagnostics[0].message.contains("running")); } }