838 lines
26 KiB
Rust
838 lines
26 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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<String>) -> 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<String>,
|
|
pub instruction: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommandExecution {
|
|
pub method: Option<Method>,
|
|
pub action: Option<CommandAction>,
|
|
pub diagnostics: Vec<CommandDiagnostic>,
|
|
pub exit_command_mode: bool,
|
|
pub clear_input: bool,
|
|
}
|
|
|
|
impl CommandExecution {
|
|
pub fn diagnostic(message: impl Into<String>) -> Self {
|
|
Self {
|
|
method: None,
|
|
action: None,
|
|
diagnostics: vec![CommandDiagnostic::new(message)],
|
|
exit_command_mode: false,
|
|
clear_input: false,
|
|
}
|
|
}
|
|
|
|
pub fn notice(message: impl Into<String>) -> 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<String>, 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<CommandArgs, CommandDiagnostic>;
|
|
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<CommandSpec>,
|
|
}
|
|
|
|
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 <pod-name>",
|
|
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 <intake|route|investigate|implement|review> ...",
|
|
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<CommandCandidate> {
|
|
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<CommandArgs, CommandDiagnostic> {
|
|
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<CommandArgs, CommandDiagnostic> {
|
|
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<CommandArgs, CommandDiagnostic> {
|
|
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<CommandArgs, CommandDiagnostic> {
|
|
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<CommandArgs, CommandDiagnostic> {
|
|
let args = CommandArgs::parse_whitespace(raw);
|
|
if args.argv().len() == 1 {
|
|
Ok(args)
|
|
} else {
|
|
Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: peer <pod-name>",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn ticket_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
|
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 <intake|route|investigate|implement|review> ...",
|
|
));
|
|
};
|
|
match action {
|
|
"intake" if args.argv().len() >= 2 => Ok(args),
|
|
"intake" => Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: ticket intake <context...>",
|
|
)),
|
|
"route" | "investigate" | "implement" | "review" if args.argv().len() >= 2 => Ok(args),
|
|
"route" => Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: ticket route <ticket-id-or-slug> [instruction...]",
|
|
)),
|
|
"investigate" => Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: ticket investigate <ticket-id-or-slug> [instruction...]",
|
|
)),
|
|
"implement" => Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: ticket implement <ticket-id-or-slug> [instruction...]",
|
|
)),
|
|
"review" => Err(CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: ticket review <ticket-id-or-slug> [instruction...]",
|
|
)),
|
|
_ => Err(CommandDiagnostic::new(format!(
|
|
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
|
|
))),
|
|
}
|
|
}
|
|
|
|
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::<Vec<_>>()
|
|
.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 <intake|route|investigate|implement|review> ...",
|
|
);
|
|
};
|
|
|
|
let Some(role) = ticket_role_for_action(action) else {
|
|
return CommandExecution::diagnostic(format!(
|
|
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
|
|
));
|
|
};
|
|
|
|
let (ticket, instruction) = if action == "intake" {
|
|
let Some(instruction) = non_empty_string(rest) else {
|
|
return CommandExecution::diagnostic(
|
|
"Invalid arguments. Usage: ticket intake <context...>",
|
|
);
|
|
};
|
|
(None, Some(instruction))
|
|
} else {
|
|
let Some((ticket, rest)) = split_first_word(rest) else {
|
|
return CommandExecution::diagnostic(format!(
|
|
"Invalid arguments. Usage: ticket {action} <ticket-id-or-slug> [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<TicketRole> {
|
|
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<String> {
|
|
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 <context...>"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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 <ticket-id-or-slug>")
|
|
);
|
|
}
|
|
|
|
#[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 <pod-name>"));
|
|
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"));
|
|
}
|
|
}
|