vtcode_desktop_protocol/
slash_command.rs1pub const WEBMCP_TOOLS_HELP: &str = "WebMCP tools: workspace.list_files, workspace.read_file, patch.propose, patch.apply, checks.run, patch.revert, turn.request, cancel";
5
6pub const WEBMCP_ROOTS_HELP: &str =
8 "Headless WebMCP roots are configured with [webmcp].allowed_roots or --allowed-root.";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum WebmcpSlashCommand {
13 Status,
15 Help,
17 Tools,
19 Roots,
21 ConnectHelp,
23 Connect {
25 origin: String,
27 replace: bool,
29 },
30 Unpair,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum WebmcpSlashCommandParseError {
37 UnknownCommand,
39 InvalidConnectionArguments,
41}
42
43pub fn parse_webmcp_slash_command(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
52 let arguments = arguments.trim();
53 if arguments.is_empty() {
54 return Ok(WebmcpSlashCommand::Status);
55 }
56
57 let (verb, tail) = arguments
58 .split_once(char::is_whitespace)
59 .map_or((arguments, ""), |(verb, tail)| (verb, tail.trim()));
60
61 match verb {
62 "status" if tail.is_empty() => Ok(WebmcpSlashCommand::Status),
63 "help" if tail.is_empty() => Ok(WebmcpSlashCommand::Help),
64 "tools" if tail.is_empty() => Ok(WebmcpSlashCommand::Tools),
65 "roots" if tail.is_empty() => Ok(WebmcpSlashCommand::Roots),
66 "unpair" | "stop" if tail.is_empty() => Ok(WebmcpSlashCommand::Unpair),
67 "connect" | "pair" => parse_connection_arguments(tail),
68 _ => Err(WebmcpSlashCommandParseError::UnknownCommand),
69 }
70}
71
72fn parse_connection_arguments(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
73 if arguments.is_empty() {
74 return Ok(WebmcpSlashCommand::ConnectHelp);
75 }
76
77 let mut tokens = arguments.split_whitespace();
78 let mut origin = None;
79 let mut replace = false;
80
81 while let Some(token) = tokens.next() {
82 match token {
83 "--replace" if !replace => replace = true,
84 "--origin" if origin.is_none() => {
85 let Some(value) = tokens.next() else {
86 return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
87 };
88 if value.starts_with('-') {
89 return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
90 }
91 origin = Some(value);
92 }
93 value if origin.is_none() && !value.starts_with('-') => origin = Some(value),
94 _ => return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments),
95 }
96 }
97
98 let Some(origin) = origin else {
99 return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
100 };
101
102 Ok(WebmcpSlashCommand::Connect { origin: origin.to_owned(), replace })
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn parses_status_help_and_webmcp_catalog_commands() {
111 assert_eq!(parse_webmcp_slash_command(""), Ok(WebmcpSlashCommand::Status));
112 assert_eq!(parse_webmcp_slash_command(" status "), Ok(WebmcpSlashCommand::Status));
113 assert_eq!(parse_webmcp_slash_command("help"), Ok(WebmcpSlashCommand::Help));
114 assert_eq!(parse_webmcp_slash_command("tools"), Ok(WebmcpSlashCommand::Tools));
115 assert_eq!(parse_webmcp_slash_command("roots"), Ok(WebmcpSlashCommand::Roots));
116 }
117
118 #[test]
119 fn parses_connect_aliases_and_options_in_either_order() {
120 assert_eq!(
121 parse_webmcp_slash_command("connect https://example.test"),
122 Ok(WebmcpSlashCommand::Connect {
123 origin: "https://example.test".into(),
124 replace: false,
125 })
126 );
127 assert_eq!(
128 parse_webmcp_slash_command("pair --replace --origin http://localhost:5173"),
129 Ok(WebmcpSlashCommand::Connect {
130 origin: "http://localhost:5173".into(),
131 replace: true,
132 })
133 );
134 assert_eq!(
135 parse_webmcp_slash_command("connect --origin http://localhost:5173 --replace"),
136 Ok(WebmcpSlashCommand::Connect {
137 origin: "http://localhost:5173".into(),
138 replace: true,
139 })
140 );
141 assert_eq!(parse_webmcp_slash_command("pair"), Ok(WebmcpSlashCommand::ConnectHelp));
142 }
143
144 #[test]
145 fn parses_both_disconnect_verbs() {
146 assert_eq!(parse_webmcp_slash_command("unpair"), Ok(WebmcpSlashCommand::Unpair));
147 assert_eq!(parse_webmcp_slash_command("stop"), Ok(WebmcpSlashCommand::Unpair));
148 }
149
150 #[test]
151 fn rejects_ambiguous_or_malformed_commands() {
152 for input in [
153 "status extra",
154 "unknown",
155 "pair https://a.test https://b.test",
156 "pair --unknown https://a.test",
157 "pair --replace --replace https://a.test",
158 "pair --origin --replace",
159 "connect --replace",
160 ] {
161 assert!(parse_webmcp_slash_command(input).is_err(), "accepted {input:?}");
162 }
163 }
164}