use std::collections::{BTreeMap, HashSet};
use crate::value::Value;
fn default_consumes() -> usize {
1
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ParamSchema {
pub name: String,
pub param_type: String,
pub required: bool,
pub default: Option<Value>,
pub description: String,
pub aliases: Vec<String>,
#[serde(default = "default_consumes")]
pub consumes: usize,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub repeatable: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub positional: bool,
}
impl ParamSchema {
pub fn required(name: impl Into<String>, param_type: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
param_type: param_type.into(),
required: true,
default: None,
description: description.into(),
aliases: Vec::new(),
consumes: 1,
repeatable: false,
positional: false,
}
}
pub fn optional(name: impl Into<String>, param_type: impl Into<String>, default: Value, description: impl Into<String>) -> Self {
Self {
name: name.into(),
param_type: param_type.into(),
required: false,
default: Some(default),
description: description.into(),
aliases: Vec::new(),
consumes: 1,
repeatable: false,
positional: false,
}
}
pub fn new(name: impl Into<String>, param_type: impl Into<String>) -> Self {
Self {
name: name.into(),
param_type: param_type.into(),
required: false,
default: None,
description: String::new(),
aliases: Vec::new(),
consumes: 1,
repeatable: false,
positional: false,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
pub fn with_default(mut self, default: Option<Value>) -> Self {
self.default = default;
self
}
pub fn with_positional(mut self, positional: bool) -> Self {
self.positional = positional;
self
}
pub fn positional(mut self) -> Self {
self.positional = true;
self
}
pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.aliases = aliases.into_iter().map(Into::into).collect();
self
}
pub fn consumes(mut self, n: usize) -> Self {
assert!(n >= 1, "ParamSchema::consumes requires n >= 1 (use a bool param for flags that take no value)");
self.consumes = n;
self
}
pub fn with_repeatable(mut self, repeatable: bool) -> Self {
self.repeatable = repeatable;
self
}
pub fn matches_flag(&self, flag: &str) -> bool {
if self.name == flag {
return true;
}
self.aliases.iter().any(|a| a == flag)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Example {
pub description: String,
pub code: String,
}
impl Example {
pub fn new(description: impl Into<String>, code: impl Into<String>) -> Self {
Self {
description: description.into(),
code: code.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ArgBinding {
#[default]
Typed,
Verbatim,
}
impl ArgBinding {
pub fn is_typed(&self) -> bool {
matches!(self, ArgBinding::Typed)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub params: Vec<ParamSchema>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub typed_substitution: bool,
pub examples: Vec<Example>,
pub map_positionals: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subcommands: Vec<ToolSchema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub owns_output: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub raw_argv: bool,
#[serde(default, skip_serializing_if = "ArgBinding::is_typed")]
pub arg_binding: ArgBinding,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub glob_passthrough: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operations: Vec<String>,
}
impl ToolSchema {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
params: Vec::new(),
examples: Vec::new(),
map_positionals: false,
subcommands: Vec::new(),
aliases: Vec::new(),
owns_output: false,
raw_argv: false,
arg_binding: ArgBinding::Typed,
glob_passthrough: false,
typed_substitution: false,
operations: Vec::new(),
}
}
pub fn with_raw_argv(mut self) -> Self {
self.raw_argv = true;
self
}
pub fn with_verbatim_argv(mut self) -> Self {
self.arg_binding = ArgBinding::Verbatim;
self
}
pub fn with_typed_substitution(mut self) -> Self {
self.typed_substitution = true;
self
}
pub fn with_glob_passthrough(mut self) -> Self {
self.glob_passthrough = true;
self
}
pub fn with_operations(mut self, operations: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.operations = operations.into_iter().map(Into::into).collect();
self
}
pub fn with_positional_mapping(mut self) -> Self {
self.map_positionals = true;
self
}
pub fn param(mut self, param: ParamSchema) -> Self {
self.params.push(param);
self
}
pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
self.examples.push(Example::new(description, code));
self
}
pub fn subcommand(mut self, child: ToolSchema) -> Self {
self.subcommands.push(child);
self
}
pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.aliases = aliases.into_iter().map(Into::into).collect();
self
}
pub fn matches_command(&self, word: &str) -> bool {
self.name == word || self.aliases.iter().any(|a| a == word)
}
pub fn with_owned_output(mut self) -> Self {
self.mark_owned_output();
self
}
fn mark_owned_output(&mut self) {
self.owns_output = true;
if !self.params.iter().any(|p| p.name == "json") {
self.params.push(
ParamSchema::new("json", "bool").with_description("Render output as JSON"),
);
}
for child in &mut self.subcommands {
child.mark_owned_output();
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ToolArgs {
pub positional: Vec<Value>,
pub named: BTreeMap<String, Value>,
pub flags: HashSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub words: Option<Vec<Value>>,
}
impl ToolArgs {
pub fn new() -> Self {
Self::default()
}
pub fn words_argv(&self) -> Vec<String> {
self.words
.as_deref()
.unwrap_or_default()
.iter()
.map(value_to_argv_token)
.collect()
}
pub fn get_positional(&self, index: usize) -> Option<&Value> {
self.positional.get(index)
}
pub fn get_named(&self, key: &str) -> Option<&Value> {
self.named.get(key)
}
pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
self.named.get(name).or_else(|| self.positional.get(positional_index))
}
pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
self.get(name, positional_index).and_then(|v| match v {
Value::String(s) => Some(s.clone()),
Value::Int(i) => Some(i.to_string()),
Value::Float(f) => Some(f.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
}
pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
self.get(name, positional_index).and_then(|v| match v {
Value::Bool(b) => Some(*b),
Value::String(s) => match s.as_str() {
"true" | "yes" | "1" => Some(true),
"false" | "no" | "0" => Some(false),
_ => None,
},
Value::Int(i) => Some(*i != 0),
_ => None,
})
}
pub fn has_flag(&self, name: &str) -> bool {
if self.flags.contains(name) {
return true;
}
self.named.get(name).is_some_and(|v| match v {
Value::Bool(b) => *b,
Value::String(s) => !s.is_empty() && s != "false" && s != "0",
_ => true,
})
}
pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
let value_keys: HashSet<&str> = schema
.params
.iter()
.filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
.flat_map(|p| {
std::iter::once(p.name.as_str())
.chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
})
.collect();
let bool_keys: Vec<String> = self
.named
.iter()
.filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
.map(|(k, _)| k.clone())
.collect();
for k in bool_keys {
if let Some(Value::Bool(true)) = self.named.remove(&k) {
self.flags.insert(k);
}
}
}
pub fn to_argv(&self) -> Result<Vec<String>, ToolArgvError> {
self.to_argv_excluding(&[])
}
pub fn to_argv_excluding(&self, exclude: &[&str]) -> Result<Vec<String>, ToolArgvError> {
let mut argv = Vec::with_capacity(
self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
);
let mut flags: Vec<&String> = self.flags.iter().collect();
flags.sort();
for flag in flags {
argv.push(flag_token(flag));
}
for (key, value) in &self.named {
if exclude.contains(&key.as_str()) {
continue;
}
for rendered in render_named_value(key, value)? {
argv.push(format!("{}={}", flag_token(key), rendered));
}
}
if !self.positional.is_empty() {
argv.push("--".to_string());
for value in &self.positional {
argv.push(value_to_argv_token(value));
}
}
Ok(argv)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ToolArgvError {
#[error(
"argument `{key}` holds {byte_len} binary bytes, which cannot cross the argv/text \
boundary — read it from the raw ToolArgs value (e.g. `args.get(\"{key}\", ..)`) \
instead of the clap-parsed field"
)]
BinaryNamedValue {
key: String,
byte_len: usize,
},
}
fn flag_token(name: &str) -> String {
if name.chars().count() == 1 {
format!("-{name}")
} else {
format!("--{name}")
}
}
fn is_bool_param_type(param_type: &str) -> bool {
param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
}
fn render_named_value(key: &str, value: &Value) -> Result<Vec<String>, ToolArgvError> {
match value {
Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
Ok(outer
.iter()
.map(|inner| {
inner
.as_array()
.map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
.unwrap_or_default()
})
.collect())
}
Value::Bytes(data) => Err(ToolArgvError::BinaryNamedValue {
key: key.to_string(),
byte_len: data.len(),
}),
_ => Ok(vec![value_to_argv_token(value)]),
}
}
fn value_to_argv_token(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => s.clone(),
Value::Json(j) => j.to_string(),
Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
}
}
pub fn global_flag_value_is_truthy(value: &Value) -> bool {
match value {
Value::Bool(b) => *b,
Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0,
Value::String(s) => !s.is_empty() && s != "false" && s != "0",
_ => true,
}
}
fn json_value_to_token(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => String::new(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod schema_serde_tests {
use super::*;
#[test]
fn flat_schema_omits_new_fields_on_wire() {
let schema = ToolSchema::new("cat", "concatenate")
.param(ParamSchema::required("path", "string", "file to read").positional());
let json = serde_json::to_value(&schema).expect("serialize");
let obj = json.as_object().expect("object");
assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
}
#[test]
fn flat_wire_form_deserializes_to_empty() {
let flat = serde_json::json!({
"name": "cat",
"description": "concatenate",
"params": [],
"examples": [],
"map_positionals": false
});
let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
assert!(schema.subcommands.is_empty());
assert!(schema.aliases.is_empty());
}
#[test]
fn with_owned_output_marks_tree_and_advertises_json() {
let schema = ToolSchema::new("kj", "kaijutsu")
.subcommand(
ToolSchema::new("context", "ctx")
.subcommand(ToolSchema::new("list", "list contexts")),
)
.with_owned_output();
assert!(schema.owns_output, "root marked");
assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
let context = &schema.subcommands[0];
assert!(context.owns_output, "child marked");
let list = &context.subcommands[0];
assert!(list.owns_output, "grandchild marked");
assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
}
#[test]
fn with_owned_output_does_not_double_add_json() {
let schema = ToolSchema::new("kj", "kaijutsu")
.param(ParamSchema::new("json", "bool"))
.with_owned_output();
let json_count = schema.params.iter().filter(|p| p.name == "json").count();
assert_eq!(json_count, 1, "json should appear exactly once");
}
#[test]
fn owns_output_serde() {
let flat = ToolSchema::new("ls", "list");
let json = serde_json::to_value(&flat).expect("serialize");
let obj = json.as_object().expect("object");
assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
let wire = serde_json::to_string(&owned).expect("serialize");
let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
assert!(back.owns_output);
}
#[test]
fn subcommand_tree_round_trips() {
let schema = ToolSchema::new("kj", "kaijutsu")
.subcommand(
ToolSchema::new("context", "context ops")
.with_command_aliases(["ctx"])
.subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
);
let json = serde_json::to_string(&schema).expect("serialize");
let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.subcommands.len(), 1);
let context = &back.subcommands[0];
assert!(context.matches_command("context"));
assert!(context.matches_command("ctx"));
assert_eq!(context.subcommands.len(), 1);
assert!(context.subcommands[0].matches_command("ls"));
}
}
#[cfg(test)]
mod to_argv_tests {
use super::*;
#[test]
fn empty_args_produce_empty_argv() {
assert!(ToolArgs::new().to_argv().unwrap().is_empty());
}
#[test]
fn positionals_emitted_after_double_dash() {
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("world".into()));
assert_eq!(args.to_argv().unwrap(), vec!["--", "hello", "world"]);
}
#[test]
fn single_char_flags_emit_short_form() {
let mut args = ToolArgs::new();
args.flags.insert("n".into());
args.flags.insert("verbose".into());
assert_eq!(args.to_argv().unwrap(), vec!["-n", "--verbose"]);
}
#[test]
fn named_values_use_equals_form() {
let mut args = ToolArgs::new();
args.named.insert("count".into(), Value::Int(5));
args.named.insert("name".into(), Value::String("foo".into()));
assert_eq!(args.to_argv().unwrap(), vec!["--count=5", "--name=foo"]);
}
#[test]
fn single_char_named_emits_short_equals() {
let mut args = ToolArgs::new();
args.named.insert("n".into(), Value::Int(5));
assert_eq!(args.to_argv().unwrap(), vec!["-n=5"]);
}
#[test]
fn positional_with_leading_dash_survives_double_dash() {
let mut args = ToolArgs::new();
args.positional.push(Value::String("-n".into()));
assert_eq!(args.to_argv().unwrap(), vec!["--", "-n"]);
}
#[test]
fn mixed_flags_named_positionals() {
let mut args = ToolArgs::new();
args.flags.insert("verbose".into());
args.named.insert("limit".into(), Value::Int(10));
args.positional.push(Value::String("file.txt".into()));
assert_eq!(
args.to_argv().unwrap(),
vec!["--verbose", "--limit=10", "--", "file.txt"]
);
}
#[test]
fn named_bytes_value_errors_loudly() {
let mut args = ToolArgs::new();
args.named.insert("separator".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
let err = args.to_argv().expect_err("named Bytes must error");
let message = err.to_string();
assert!(message.contains("separator"));
assert!(message.contains('3'));
let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
assert_eq!(key, "separator");
assert_eq!(byte_len, 3);
}
#[test]
fn single_char_named_bytes_value_errors_loudly() {
let mut args = ToolArgs::new();
args.named.insert("a".into(), Value::Bytes(vec![1, 2]));
let err = args.to_argv().expect_err("named Bytes must error");
let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
assert_eq!(key, "a");
assert_eq!(byte_len, 2);
}
#[test]
fn positional_bytes_value_renders_placeholder_not_error() {
let mut args = ToolArgs::new();
args.positional.push(Value::Bytes(vec![0xff, 0x00, 0xfe]));
let argv = args.to_argv().expect("positional Bytes must not error");
assert_eq!(argv, vec!["--", "[binary: 3 bytes]"]);
}
#[test]
fn named_bytes_errors_even_with_positional_bytes_present() {
let mut args = ToolArgs::new();
args.named.insert("check".into(), Value::Bytes(vec![9, 9]));
args.positional.push(Value::Bytes(vec![1, 2, 3]));
let err = args.to_argv().expect_err("named Bytes must still error");
let ToolArgvError::BinaryNamedValue { key, .. } = err;
assert_eq!(key, "check");
}
#[test]
fn to_argv_excluding_skips_excluded_named_bytes_without_error() {
let mut args = ToolArgs::new();
args.named.insert("content".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
args.named.insert("path".into(), Value::String("dest.bin".into()));
let argv = args
.to_argv_excluding(&["content"])
.expect("excluded named Bytes must not error");
assert_eq!(argv, vec!["--path=dest.bin"]);
assert!(
argv.iter().all(|tok| !tok.contains("content")),
"excluded key must not appear in argv at all: {argv:?}"
);
}
#[test]
fn to_argv_excluding_still_errors_on_non_excluded_named_bytes() {
let mut args = ToolArgs::new();
args.named.insert("content".into(), Value::Bytes(vec![1, 2, 3]));
args.named.insert("separator".into(), Value::Bytes(vec![9, 9]));
let err = args
.to_argv_excluding(&["content"])
.expect_err("non-excluded named Bytes must still error");
let ToolArgvError::BinaryNamedValue { key, .. } = err;
assert_eq!(key, "separator");
}
#[test]
fn to_argv_excluding_drops_excluded_key_regardless_of_value_type() {
let mut args = ToolArgs::new();
args.named.insert("content".into(), Value::String("hello".into()));
args.named.insert("path".into(), Value::String("dest.txt".into()));
let argv = args.to_argv_excluding(&["content"]).expect("no error expected");
assert_eq!(argv, vec!["--path=dest.txt"]);
}
#[test]
fn to_argv_excluding_empty_list_matches_to_argv() {
let mut args = ToolArgs::new();
args.flags.insert("verbose".into());
args.flags.insert("n".into());
args.named.insert("limit".into(), Value::Int(10));
args.named.insert("name".into(), Value::String("foo".into()));
args.positional.push(Value::String("file.txt".into()));
args.positional.push(Value::String("-weird".into()));
assert_eq!(
args.to_argv_excluding(&[]).unwrap(),
args.to_argv().unwrap(),
"empty exclude list must be indistinguishable from to_argv()"
);
}
#[test]
fn flagify_bool_named_promotes_true_to_flag() {
let mut args = ToolArgs::new();
args.named.insert("recursive".into(), Value::Bool(true));
args.named.insert("limit".into(), Value::Int(5));
args.flagify_bool_named(&ToolSchema::new("t", ""));
assert!(args.flags.contains("recursive"));
assert!(!args.named.contains_key("recursive"));
assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
}
#[test]
fn flagify_bool_named_drops_false() {
let mut args = ToolArgs::new();
args.named.insert("recursive".into(), Value::Bool(false));
args.flagify_bool_named(&ToolSchema::new("t", ""));
assert!(!args.flags.contains("recursive"));
assert!(!args.named.contains_key("recursive"));
}
#[test]
fn flagify_bool_named_is_idempotent() {
let mut args = ToolArgs::new();
args.named.insert("recursive".into(), Value::Bool(true));
args.flagify_bool_named(&ToolSchema::new("t", ""));
args.flagify_bool_named(&ToolSchema::new("t", ""));
assert!(args.flags.contains("recursive"));
}
#[test]
fn flagify_bool_named_round_trips_through_to_argv() {
let mut args = ToolArgs::new();
args.named.insert("R".into(), Value::Bool(true));
args.flagify_bool_named(&ToolSchema::new("t", ""));
let argv = args.to_argv().unwrap();
assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
}
#[test]
fn flagify_bool_named_keeps_value_flag_value() {
let mut schema = ToolSchema::new("spawn", "");
schema.params.push(ParamSchema::new("command", "string"));
let mut args = ToolArgs::new();
args.named.insert("command".into(), Value::Bool(true));
args.flagify_bool_named(&schema);
assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
let argv = args.to_argv().unwrap();
assert!(
argv.iter().any(|s| s == "--command=true"),
"expected --command=true, got {:?}",
argv
);
}
#[test]
fn flagify_bool_named_distinguishes_bool_from_value_param() {
let mut schema = ToolSchema::new("t", "");
schema.params.push(ParamSchema::new("verbose", "bool"));
schema.params.push(ParamSchema::new("command", "string"));
let mut args = ToolArgs::new();
args.named.insert("verbose".into(), Value::Bool(true));
args.named.insert("command".into(), Value::Bool(true));
args.flagify_bool_named(&schema);
assert!(args.flags.contains("verbose"));
assert!(!args.named.contains_key("verbose"));
assert!(!args.flags.contains("command"));
assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
}
}
#[cfg(test)]
mod verbatim_words_tests {
use super::*;
#[test]
fn typed_args_render_no_words() {
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
assert!(args.words.is_none());
assert!(args.words_argv().is_empty());
}
#[test]
fn words_render_in_order_with_repeats() {
let mut args = ToolArgs::new();
args.words = Some(vec![
Value::String("block".into()),
Value::String("list".into()),
Value::String("--limit".into()),
Value::Int(5),
Value::String("--include".into()),
Value::String("a".into()),
Value::String("--include".into()),
Value::String("b".into()),
]);
assert_eq!(
args.words_argv(),
vec!["block", "list", "--limit", "5", "--include", "a", "--include", "b"],
);
}
#[test]
fn binary_word_renders_as_a_placeholder_and_keeps_its_bytes() {
let mut args = ToolArgs::new();
args.words = Some(vec![
Value::String("write".into()),
Value::Bytes(vec![0, 159, 146, 150]),
]);
let argv = args.words_argv();
assert_eq!(argv[0], "write");
assert_ne!(argv[1], "", "a binary word still needs an argv token");
assert!(
!argv[1].as_bytes().contains(&0),
"the placeholder must be text, not the raw bytes; got {:?}",
argv[1],
);
let words = args.words.as_deref().expect("words");
assert_eq!(words[1], Value::Bytes(vec![0, 159, 146, 150]));
}
}