tui: fuzz slash command roundtrips

Add registry-driven proptest coverage for slash command serialization in tui and tui_app_server. The new property suite surfaced a remainder quoting bug, so the shared slash protocol now shell-quotes non-roundtrippable remainder text before replay parsing.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-22 15:15:37 -07:00
co-authored by Codex
parent 91901741bd
commit ed5c86fcfc
8 changed files with 968 additions and 16 deletions
+43 -4
View File
@@ -965,7 +965,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
"bit-vec 0.6.3",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
@@ -974,6 +983,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -2624,6 +2639,7 @@ dependencies = [
"libc",
"pathdiff",
"pretty_assertions",
"proptest",
"pulldown-cmark",
"rand 0.9.2",
"ratatui",
@@ -2716,6 +2732,7 @@ dependencies = [
"libc",
"pathdiff",
"pretty_assertions",
"proptest",
"pulldown-cmark",
"rand 0.9.2",
"ratatui",
@@ -5305,7 +5322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
"quick-error 2.0.1",
]
[[package]]
@@ -5661,7 +5678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b"
dependencies = [
"ascii-canvas",
"bit-set",
"bit-set 0.5.3",
"diff",
"ena",
"is-terminal",
@@ -7345,12 +7362,16 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.10.0",
"num-traits",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax 0.8.8",
"rusty-fork",
"tempfile",
"unarray",
]
@@ -7420,6 +7441,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -8384,6 +8411,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustyline"
version = "14.0.0"
@@ -9963,7 +10002,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"quick-error 2.0.1",
"weezl",
"zune-jpeg 0.4.21",
]
+1
View File
@@ -237,6 +237,7 @@ pathdiff = "0.2"
portable-pty = "0.9.0"
predicates = "3"
pretty_assertions = "1.4.1"
proptest = "1.7.0"
pulldown-cmark = "0.10"
quick-xml = "0.38.4"
rand = "0.9"
+1
View File
@@ -142,6 +142,7 @@ assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
pretty_assertions = { workspace = true }
proptest = { workspace = true }
rand = { workspace = true }
serial_test = { workspace = true }
vt100 = { workspace = true }
+383
View File
@@ -1091,11 +1091,394 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
#[cfg(test)]
mod tests {
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use pretty_assertions::assert_eq;
use proptest::prelude::*;
use proptest::sample::select;
use proptest::test_runner::Config as ProptestConfig;
use proptest::test_runner::TestCaseError;
use proptest::test_runner::TestRunner;
use std::str::FromStr;
use super::*;
fn placeholder_text_arg(placeholder: &str) -> SlashTextArg {
SlashTextArg::new(
placeholder.to_string(),
vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.to_string()),
)],
)
}
fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> {
if element.byte_range.end <= offset {
return None;
}
let start = element.byte_range.start.saturating_sub(offset);
let end = element.byte_range.end.saturating_sub(offset);
(start < end).then(|| element.map_range(|_| ByteRange { start, end }))
}
fn serialized_args(
invocation: &SlashCommandInvocation,
) -> (String, Vec<codex_protocol::user_input::TextElement>) {
let serialized = invocation.serialize();
let prefix = format!("/{}", invocation.command().command());
let args = serialized
.text
.strip_prefix(&prefix)
.expect("serialized invocation should start with command prefix");
let Some(args) = args.strip_prefix(' ') else {
return (String::new(), Vec::new());
};
let offset = prefix.len() + 1;
let elements = serialized
.text_elements
.iter()
.filter_map(|element| shift_text_element_left(element, offset))
.collect();
(args.to_string(), elements)
}
fn token_text_strategy() -> BoxedStrategy<String> {
prop_oneof![
proptest::string::string_regex("[A-Za-z0-9._/-]{1,16}").unwrap(),
(
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(),
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(),
)
.prop_map(|(lhs, rhs)| format!("{lhs} {rhs}")),
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}[\"'][A-Za-z0-9._/-]{1,8}")
.unwrap(),
]
.boxed()
}
fn plain_text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
proptest::string::string_regex(
"[A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}( [A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}){0,3}",
)
.unwrap()
.prop_map(|text| SlashTextArg::new(text, Vec::new()))
.boxed()
}
fn placeholder_text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
(
proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(),
select(vec!["[Image #1]".to_string(), "[Image #12]".to_string()]),
proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(),
)
.prop_map(|(prefix, placeholder, suffix)| {
let mut text = String::new();
if !prefix.is_empty() {
text.push_str(&prefix);
text.push(' ');
}
let start = text.len();
text.push_str(&placeholder);
let end = text.len();
if !suffix.is_empty() {
text.push(' ');
text.push_str(&suffix);
}
SlashTextArg::new(
text,
vec![TextElement::new((start..end).into(), Some(placeholder))],
)
})
.boxed()
}
fn text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
prop_oneof![plain_text_arg_strategy(), placeholder_text_arg_strategy(),].boxed()
}
fn token_arg_strategy() -> BoxedStrategy<SlashTokenArg> {
token_text_strategy()
.prop_map(|text| SlashTokenArg::new(text, Vec::new()))
.boxed()
}
impl SlashCommand {
fn roundtrip_test_invocations(self) -> Vec<SlashCommandInvocation> {
let bare = SlashCommandInvocation::Bare(self);
match self {
SlashCommand::Fast => vec![
bare,
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::On,
}),
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::Off,
}),
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::Status,
}),
],
SlashCommand::SandboxReadRoot => vec![
bare,
SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs {
path: SlashTokenArg::new("/tmp/test-dir".to_string(), Vec::new()),
}),
],
SlashCommand::Review => vec![
bare,
SlashCommandInvocation::Review(ReviewArgs {
instructions: placeholder_text_arg("[Image #1]"),
}),
],
SlashCommand::Rename => vec![
bare,
SlashCommandInvocation::Rename(RenameArgs {
title: SlashTextArg::new("ship it".to_string(), Vec::new()),
}),
],
SlashCommand::Plan => vec![
bare,
SlashCommandInvocation::Plan(PlanArgs {
prompt: SlashTextArg::new("investigate flaky test".to_string(), Vec::new()),
}),
],
SlashCommand::Statusline => vec![
bare,
SlashCommandInvocation::Statusline(StatuslineArgs {
items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir],
}),
],
SlashCommand::Feedback => vec![
bare,
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::BadResult,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::GoodResult,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::Bug,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::SafetyCheck,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::Other,
}),
],
SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Title
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MultiAgents
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => vec![bare],
}
}
fn roundtrip_strategy(self) -> BoxedStrategy<SlashCommandInvocation> {
let bare = Just(SlashCommandInvocation::Bare(self));
match self {
SlashCommand::Fast => prop_oneof![
bare,
select(vec![
FastSlashCommandArgs::On,
FastSlashCommandArgs::Off,
FastSlashCommandArgs::Status,
])
.prop_map(|mode| SlashCommandInvocation::Fast(FastArgs { mode })),
]
.boxed(),
SlashCommand::SandboxReadRoot => prop_oneof![
bare,
token_arg_strategy().prop_map(|path| {
SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { path })
}),
]
.boxed(),
SlashCommand::Review => prop_oneof![
bare,
text_arg_strategy().prop_map(|instructions| {
SlashCommandInvocation::Review(ReviewArgs { instructions })
}),
]
.boxed(),
SlashCommand::Rename => prop_oneof![
bare,
plain_text_arg_strategy()
.prop_map(|title| { SlashCommandInvocation::Rename(RenameArgs { title }) }),
]
.boxed(),
SlashCommand::Plan => prop_oneof![
bare,
text_arg_strategy()
.prop_map(|prompt| { SlashCommandInvocation::Plan(PlanArgs { prompt }) }),
]
.boxed(),
SlashCommand::Statusline => {
let items = vec![
StatusLineItem::ModelName,
StatusLineItem::ModelWithReasoning,
StatusLineItem::CurrentDir,
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ContextRemaining,
StatusLineItem::ContextUsed,
StatusLineItem::FiveHourLimit,
StatusLineItem::WeeklyLimit,
StatusLineItem::CodexVersion,
StatusLineItem::ContextWindowSize,
StatusLineItem::UsedTokens,
StatusLineItem::TotalInputTokens,
StatusLineItem::TotalOutputTokens,
StatusLineItem::SessionId,
StatusLineItem::FastMode,
];
prop_oneof![
bare,
proptest::collection::vec(select(items), 1..5).prop_map(|items| {
SlashCommandInvocation::Statusline(StatuslineArgs { items })
}),
]
.boxed()
}
SlashCommand::Feedback => prop_oneof![
bare,
select(vec![
FeedbackCategory::BadResult,
FeedbackCategory::GoodResult,
FeedbackCategory::Bug,
FeedbackCategory::SafetyCheck,
FeedbackCategory::Other,
])
.prop_map(|category| {
SlashCommandInvocation::Feedback(FeedbackArgs { category })
}),
]
.boxed(),
SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Title
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MultiAgents
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => bare.boxed(),
}
}
}
#[test]
fn all_registered_commands_roundtrip_from_serialized_text() {
for spec in SLASH_COMMAND_SPECS {
for invocation in spec.command.roundtrip_test_invocations() {
let (args, text_elements) = serialized_args(&invocation);
assert_eq!(
spec.command.parse_invocation(&args, &text_elements),
Ok(invocation.clone()),
"roundtrip failed for /{} with serialized {:?}",
spec.command.command(),
invocation.serialize().text
);
}
}
}
#[test]
fn all_registered_commands_proptest_roundtrip_from_serialized_text() {
for spec in SLASH_COMMAND_SPECS {
let command = spec.command;
let mut runner = TestRunner::new(ProptestConfig {
cases: 24,
failure_persistence: None,
..ProptestConfig::default()
});
runner
.run(&command.roundtrip_strategy(), |invocation| {
let serialized = invocation.serialize();
let (args, text_elements) = serialized_args(&invocation);
let reparsed =
command
.parse_invocation(&args, &text_elements)
.map_err(|err| {
TestCaseError::fail(format!(
"roundtrip parse failed for /{} from {:?}: {err:?}",
command.command(),
serialized.text
))
})?;
prop_assert_eq!(reparsed, invocation);
Ok(())
})
.unwrap_or_else(|err| {
panic!(
"property roundtrip failed for /{}: {err}",
command.command()
)
});
}
}
#[test]
fn approvals_alias_is_hidden_from_command_popup() {
assert!(SlashCommand::Approvals.hide_in_command_popup());
+79 -6
View File
@@ -210,7 +210,7 @@ impl<'a> SlashArgsParser<'a> {
}
pub(crate) fn remainder(&self) -> Option<SlashTextArg> {
trim_text_arg(self.input.args, self.input.text_elements)
parse_remainder_text_arg(self.input.args, self.input.text_elements)
}
pub(crate) fn required_remainder(&self) -> Result<SlashTextArg, SlashCommandUsageErrorKind> {
@@ -265,10 +265,17 @@ impl SlashArgsSerializer {
}
pub(crate) fn remainder(&mut self, value: &SlashTextArg) {
self.fragments.push(SlashSerializedText {
text: value.text.clone(),
text_elements: value.text_elements.clone(),
});
if remainder_can_roundtrip_raw(value) {
self.fragments.push(SlashSerializedText {
text: value.text.clone(),
text_elements: value.text_elements.clone(),
});
} else {
self.fragments.push(serialize_token(&SlashTokenArg::new(
value.text.clone(),
value.text_elements.clone(),
)));
}
}
pub(crate) fn finish(self) -> SlashSerializedText {
@@ -299,6 +306,30 @@ fn trim_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextA
Some(SlashTextArg::new(trimmed.to_string(), elements))
}
fn parse_remainder_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> {
let trimmed = trim_text_arg(text, text_elements)?;
match tokenize_with_elements(&trimmed.text, &trimmed.text_elements) {
Ok(tokens) => match tokens.as_slice() {
[token] => Some(SlashTextArg::new(
token.text.clone(),
token.text_elements.clone(),
)),
_ => Some(trimmed),
},
_ => Some(trimmed),
}
}
fn remainder_can_roundtrip_raw(value: &SlashTextArg) -> bool {
match tokenize_with_elements(&value.text, &value.text_elements) {
Ok(tokens) if tokens.len() == 1 => {
tokens[0] == SlashTokenArg::new(value.text.clone(), value.text_elements.clone())
}
Ok(_) => true,
Err(_) => false,
}
}
fn split_named_arg(token: &SlashTokenArg) -> Option<(String, SlashTokenArg)> {
let rest = token.text.strip_prefix("--")?;
let (key, value) = rest.split_once('=')?;
@@ -345,10 +376,28 @@ fn serialize_token(token: &SlashTokenArg) -> SlashSerializedText {
let (token_for_shlex, replacements) =
replace_text_elements_with_sentinels(&token.text, &token.text_elements);
let quoted = try_join([token_for_shlex.as_str()]).unwrap_or_else(|_| token_for_shlex.clone());
let quoted = try_join([token_for_shlex.as_str()])
.unwrap_or_else(|_| shell_quote_token(&token_for_shlex));
restore_sentinels_in_fragment(quoted, &replacements)
}
fn shell_quote_token(token: &str) -> String {
if token.is_empty() {
return "''".to_string();
}
let mut quoted = String::from("'");
for ch in token.chars() {
if ch == '\'' {
quoted.push_str("'\"'\"'");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn join_serialized_fragments(fragments: Vec<SlashSerializedText>) -> SlashSerializedText {
let mut text = String::new();
let mut text_elements = Vec::new();
@@ -590,4 +639,28 @@ mod tests {
}
);
}
#[test]
fn remainder_quotes_shell_sensitive_text_when_needed() {
let prompt = SlashTextArg::new("a\"\" a\"".to_string(), Vec::new());
let mut serializer = SlashArgsSerializer::default();
serializer.remainder(&prompt);
assert_eq!(
serializer.finish(),
SlashSerializedText {
text: "'a\"\" a\"'".to_string(),
text_elements: Vec::new(),
}
);
assert_eq!(
SlashArgsParser::new(SlashCommandParseInput {
args: "'a\"\" a\"'",
text_elements: &[],
})
.unwrap()
.required_remainder(),
Ok(prompt)
);
}
}
+1
View File
@@ -145,6 +145,7 @@ assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
pretty_assertions = { workspace = true }
proptest = { workspace = true }
rand = { workspace = true }
serial_test = { workspace = true }
vt100 = { workspace = true }
@@ -1077,11 +1077,392 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
#[cfg(test)]
mod tests {
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use pretty_assertions::assert_eq;
use proptest::prelude::*;
use proptest::sample::select;
use proptest::test_runner::Config as ProptestConfig;
use proptest::test_runner::TestCaseError;
use proptest::test_runner::TestRunner;
use std::str::FromStr;
use super::*;
fn placeholder_text_arg(placeholder: &str) -> SlashTextArg {
SlashTextArg::new(
placeholder.to_string(),
vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.to_string()),
)],
)
}
fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> {
if element.byte_range.end <= offset {
return None;
}
let start = element.byte_range.start.saturating_sub(offset);
let end = element.byte_range.end.saturating_sub(offset);
(start < end).then(|| element.map_range(|_| ByteRange { start, end }))
}
fn serialized_args(
invocation: &SlashCommandInvocation,
) -> (String, Vec<codex_protocol::user_input::TextElement>) {
let serialized = invocation.serialize();
let prefix = format!("/{}", invocation.command().command());
let args = serialized
.text
.strip_prefix(&prefix)
.expect("serialized invocation should start with command prefix");
let Some(args) = args.strip_prefix(' ') else {
return (String::new(), Vec::new());
};
let offset = prefix.len() + 1;
let elements = serialized
.text_elements
.iter()
.filter_map(|element| shift_text_element_left(element, offset))
.collect();
(args.to_string(), elements)
}
fn token_text_strategy() -> BoxedStrategy<String> {
prop_oneof![
proptest::string::string_regex("[A-Za-z0-9._/-]{1,16}").unwrap(),
(
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(),
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(),
)
.prop_map(|(lhs, rhs)| format!("{lhs} {rhs}")),
proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}[\"'][A-Za-z0-9._/-]{1,8}")
.unwrap(),
]
.boxed()
}
fn plain_text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
proptest::string::string_regex(
"[A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}( [A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}){0,3}",
)
.unwrap()
.prop_map(|text| SlashTextArg::new(text, Vec::new()))
.boxed()
}
fn placeholder_text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
(
proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(),
select(vec!["[Image #1]".to_string(), "[Image #12]".to_string()]),
proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(),
)
.prop_map(|(prefix, placeholder, suffix)| {
let mut text = String::new();
if !prefix.is_empty() {
text.push_str(&prefix);
text.push(' ');
}
let start = text.len();
text.push_str(&placeholder);
let end = text.len();
if !suffix.is_empty() {
text.push(' ');
text.push_str(&suffix);
}
SlashTextArg::new(
text,
vec![TextElement::new((start..end).into(), Some(placeholder))],
)
})
.boxed()
}
fn text_arg_strategy() -> BoxedStrategy<SlashTextArg> {
prop_oneof![plain_text_arg_strategy(), placeholder_text_arg_strategy(),].boxed()
}
fn token_arg_strategy() -> BoxedStrategy<SlashTokenArg> {
token_text_strategy()
.prop_map(|text| SlashTokenArg::new(text, Vec::new()))
.boxed()
}
impl SlashCommand {
fn roundtrip_test_invocations(self) -> Vec<SlashCommandInvocation> {
let bare = SlashCommandInvocation::Bare(self);
match self {
SlashCommand::Fast => vec![
bare,
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::On,
}),
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::Off,
}),
SlashCommandInvocation::Fast(FastArgs {
mode: FastSlashCommandArgs::Status,
}),
],
SlashCommand::SandboxReadRoot => vec![
bare,
SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs {
path: SlashTokenArg::new("/tmp/test-dir".to_string(), Vec::new()),
}),
],
SlashCommand::Review => vec![
bare,
SlashCommandInvocation::Review(ReviewArgs {
instructions: placeholder_text_arg("[Image #1]"),
}),
],
SlashCommand::Rename => vec![
bare,
SlashCommandInvocation::Rename(RenameArgs {
title: SlashTextArg::new("ship it".to_string(), Vec::new()),
}),
],
SlashCommand::Plan => vec![
bare,
SlashCommandInvocation::Plan(PlanArgs {
prompt: SlashTextArg::new("investigate flaky test".to_string(), Vec::new()),
}),
],
SlashCommand::Statusline => vec![
bare,
SlashCommandInvocation::Statusline(StatuslineArgs {
items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir],
}),
],
SlashCommand::Feedback => vec![
bare,
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::BadResult,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::GoodResult,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::Bug,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::SafetyCheck,
}),
SlashCommandInvocation::Feedback(FeedbackArgs {
category: FeedbackCategory::Other,
}),
],
SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MultiAgents
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => vec![bare],
}
}
fn roundtrip_strategy(self) -> BoxedStrategy<SlashCommandInvocation> {
let bare = Just(SlashCommandInvocation::Bare(self));
match self {
SlashCommand::Fast => prop_oneof![
bare,
select(vec![
FastSlashCommandArgs::On,
FastSlashCommandArgs::Off,
FastSlashCommandArgs::Status,
])
.prop_map(|mode| SlashCommandInvocation::Fast(FastArgs { mode })),
]
.boxed(),
SlashCommand::SandboxReadRoot => prop_oneof![
bare,
token_arg_strategy().prop_map(|path| {
SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { path })
}),
]
.boxed(),
SlashCommand::Review => prop_oneof![
bare,
text_arg_strategy().prop_map(|instructions| {
SlashCommandInvocation::Review(ReviewArgs { instructions })
}),
]
.boxed(),
SlashCommand::Rename => prop_oneof![
bare,
plain_text_arg_strategy()
.prop_map(|title| { SlashCommandInvocation::Rename(RenameArgs { title }) }),
]
.boxed(),
SlashCommand::Plan => prop_oneof![
bare,
text_arg_strategy()
.prop_map(|prompt| { SlashCommandInvocation::Plan(PlanArgs { prompt }) }),
]
.boxed(),
SlashCommand::Statusline => {
let items = vec![
StatusLineItem::ModelName,
StatusLineItem::ModelWithReasoning,
StatusLineItem::CurrentDir,
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ContextRemaining,
StatusLineItem::ContextUsed,
StatusLineItem::FiveHourLimit,
StatusLineItem::WeeklyLimit,
StatusLineItem::CodexVersion,
StatusLineItem::ContextWindowSize,
StatusLineItem::UsedTokens,
StatusLineItem::TotalInputTokens,
StatusLineItem::TotalOutputTokens,
StatusLineItem::SessionId,
StatusLineItem::FastMode,
];
prop_oneof![
bare,
proptest::collection::vec(select(items), 1..5).prop_map(|items| {
SlashCommandInvocation::Statusline(StatuslineArgs { items })
}),
]
.boxed()
}
SlashCommand::Feedback => prop_oneof![
bare,
select(vec![
FeedbackCategory::BadResult,
FeedbackCategory::GoodResult,
FeedbackCategory::Bug,
FeedbackCategory::SafetyCheck,
FeedbackCategory::Other,
])
.prop_map(|category| {
SlashCommandInvocation::Feedback(FeedbackArgs { category })
}),
]
.boxed(),
SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MultiAgents
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => bare.boxed(),
}
}
}
#[test]
fn all_registered_commands_proptest_roundtrip_from_serialized_text() {
for spec in SLASH_COMMAND_SPECS {
let command = spec.command;
let mut runner = TestRunner::new(ProptestConfig {
cases: 24,
failure_persistence: None,
..ProptestConfig::default()
});
runner
.run(&command.roundtrip_strategy(), |invocation| {
let serialized = invocation.serialize();
let (args, text_elements) = serialized_args(&invocation);
let reparsed =
command
.parse_invocation(&args, &text_elements)
.map_err(|err| {
TestCaseError::fail(format!(
"roundtrip parse failed for /{} from {:?}: {err:?}",
command.command(),
serialized.text
))
})?;
prop_assert_eq!(reparsed, invocation);
Ok(())
})
.unwrap_or_else(|err| {
panic!(
"property roundtrip failed for /{}: {err}",
command.command()
)
});
}
}
#[test]
fn all_registered_commands_roundtrip_from_serialized_text() {
for spec in SLASH_COMMAND_SPECS {
for invocation in spec.command.roundtrip_test_invocations() {
let (args, text_elements) = serialized_args(&invocation);
assert_eq!(
spec.command.parse_invocation(&args, &text_elements),
Ok(invocation.clone()),
"roundtrip failed for /{} with serialized {:?}",
spec.command.command(),
invocation.serialize().text
);
}
}
}
#[test]
fn approvals_alias_is_hidden_from_command_popup() {
assert!(SlashCommand::Approvals.hide_in_command_popup());
@@ -210,7 +210,7 @@ impl<'a> SlashArgsParser<'a> {
}
pub(crate) fn remainder(&self) -> Option<SlashTextArg> {
trim_text_arg(self.input.args, self.input.text_elements)
parse_remainder_text_arg(self.input.args, self.input.text_elements)
}
pub(crate) fn required_remainder(&self) -> Result<SlashTextArg, SlashCommandUsageErrorKind> {
@@ -265,10 +265,17 @@ impl SlashArgsSerializer {
}
pub(crate) fn remainder(&mut self, value: &SlashTextArg) {
self.fragments.push(SlashSerializedText {
text: value.text.clone(),
text_elements: value.text_elements.clone(),
});
if remainder_can_roundtrip_raw(value) {
self.fragments.push(SlashSerializedText {
text: value.text.clone(),
text_elements: value.text_elements.clone(),
});
} else {
self.fragments.push(serialize_token(&SlashTokenArg::new(
value.text.clone(),
value.text_elements.clone(),
)));
}
}
pub(crate) fn finish(self) -> SlashSerializedText {
@@ -299,6 +306,30 @@ fn trim_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextA
Some(SlashTextArg::new(trimmed.to_string(), elements))
}
fn parse_remainder_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> {
let trimmed = trim_text_arg(text, text_elements)?;
match tokenize_with_elements(&trimmed.text, &trimmed.text_elements) {
Ok(tokens) => match tokens.as_slice() {
[token] => Some(SlashTextArg::new(
token.text.clone(),
token.text_elements.clone(),
)),
_ => Some(trimmed),
},
_ => Some(trimmed),
}
}
fn remainder_can_roundtrip_raw(value: &SlashTextArg) -> bool {
match tokenize_with_elements(&value.text, &value.text_elements) {
Ok(tokens) if tokens.len() == 1 => {
tokens[0] == SlashTokenArg::new(value.text.clone(), value.text_elements.clone())
}
Ok(_) => true,
Err(_) => false,
}
}
fn split_named_arg(token: &SlashTokenArg) -> Option<(String, SlashTokenArg)> {
let rest = token.text.strip_prefix("--")?;
let (key, value) = rest.split_once('=')?;
@@ -345,10 +376,28 @@ fn serialize_token(token: &SlashTokenArg) -> SlashSerializedText {
let (token_for_shlex, replacements) =
replace_text_elements_with_sentinels(&token.text, &token.text_elements);
let quoted = try_join([token_for_shlex.as_str()]).unwrap_or_else(|_| token_for_shlex.clone());
let quoted = try_join([token_for_shlex.as_str()])
.unwrap_or_else(|_| shell_quote_token(&token_for_shlex));
restore_sentinels_in_fragment(quoted, &replacements)
}
fn shell_quote_token(token: &str) -> String {
if token.is_empty() {
return "''".to_string();
}
let mut quoted = String::from("'");
for ch in token.chars() {
if ch == '\'' {
quoted.push_str("'\"'\"'");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn join_serialized_fragments(fragments: Vec<SlashSerializedText>) -> SlashSerializedText {
let mut text = String::new();
let mut text_elements = Vec::new();
@@ -590,4 +639,28 @@ mod tests {
}
);
}
#[test]
fn remainder_quotes_shell_sensitive_text_when_needed() {
let prompt = SlashTextArg::new("a\"\" a\"".to_string(), Vec::new());
let mut serializer = SlashArgsSerializer::default();
serializer.remainder(&prompt);
assert_eq!(
serializer.finish(),
SlashSerializedText {
text: "'a\"\" a\"'".to_string(),
text_elements: Vec::new(),
}
);
assert_eq!(
SlashArgsParser::new(SlashCommandParseInput {
args: "'a\"\" a\"'",
text_elements: &[],
})
.unwrap()
.required_remainder(),
Ok(prompt)
);
}
}