use std::collections::BTreeMap;
use std::ffi::OsString;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::de::{self, Deserializer};
use serde::Deserialize;
use url::Url;
use crate::RootSpec;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum InputType {
#[default]
String,
Url,
HttpsUrl,
Int,
Uint,
Float,
Bool,
Path,
File,
Dir,
Port,
Pid,
Email,
Hostname,
Ipv4,
Ipv6,
Ip,
Uuid,
Hex,
Duration,
Date,
Json,
Enum,
}
impl InputType {
pub fn from_name(raw: &str) -> Result<Self> {
let n = raw.trim().to_ascii_lowercase().replace('_', "-");
Ok(match n.as_str() {
"" | "string" | "str" | "text" => Self::String,
"url" => Self::Url,
"https" | "https-url" | "httpsurl" => Self::HttpsUrl,
"int" | "integer" => Self::Int,
"uint" | "unsigned" => Self::Uint,
"float" | "number" | "double" => Self::Float,
"bool" | "boolean" => Self::Bool,
"path" => Self::Path,
"file" => Self::File,
"dir" | "directory" => Self::Dir,
"port" => Self::Port,
"pid" => Self::Pid,
"email" => Self::Email,
"hostname" | "host" => Self::Hostname,
"ipv4" => Self::Ipv4,
"ipv6" => Self::Ipv6,
"ip" => Self::Ip,
"uuid" => Self::Uuid,
"hex" => Self::Hex,
"duration" => Self::Duration,
"date" => Self::Date,
"json" => Self::Json,
"enum" => Self::Enum,
other => bail!(
"unknown input type `{other}` (supported: string, url, https, int, uint, \
float, bool, path, file, dir, port, pid, email, hostname, ipv4, ipv6, ip, \
uuid, hex, duration, date, json, enum)"
),
})
}
pub fn as_str(self) -> &'static str {
match self {
Self::String => "string",
Self::Url => "url",
Self::HttpsUrl => "https",
Self::Int => "int",
Self::Uint => "uint",
Self::Float => "float",
Self::Bool => "bool",
Self::Path => "path",
Self::File => "file",
Self::Dir => "dir",
Self::Port => "port",
Self::Pid => "pid",
Self::Email => "email",
Self::Hostname => "hostname",
Self::Ipv4 => "ipv4",
Self::Ipv6 => "ipv6",
Self::Ip => "ip",
Self::Uuid => "uuid",
Self::Hex => "hex",
Self::Duration => "duration",
Self::Date => "date",
Self::Json => "json",
Self::Enum => "enum",
}
}
pub fn placeholder(self) -> &'static str {
match self {
Self::String | Self::Enum => "VALUE",
Self::Url => "URL",
Self::HttpsUrl => "HTTPS-URL",
Self::Int => "INT",
Self::Uint => "UINT",
Self::Float => "NUMBER",
Self::Bool => "BOOL",
Self::Path => "PATH",
Self::File => "FILE",
Self::Dir => "DIR",
Self::Port => "PORT",
Self::Pid => "PID",
Self::Email => "EMAIL",
Self::Hostname => "HOST",
Self::Ipv4 => "IPV4",
Self::Ipv6 => "IPV6",
Self::Ip => "IP",
Self::Uuid => "UUID",
Self::Hex => "HEX",
Self::Duration => "DURATION",
Self::Date => "YYYY-MM-DD",
Self::Json => "JSON",
}
}
pub fn check(self, name: &str, value: &str, cwd: Option<&Path>) -> Result<()> {
match self {
Self::String | Self::Enum => Ok(()),
Self::Url => check_url(name, value, false),
Self::HttpsUrl => check_url(name, value, true),
Self::Int => {
if value.parse::<i64>().is_err() {
bail!("input `{name}` must be an integer; got `{value}`");
}
Ok(())
}
Self::Uint => {
if value.parse::<u64>().is_err() {
bail!("input `{name}` must be a non-negative integer; got `{value}`");
}
Ok(())
}
Self::Float => match value.parse::<f64>() {
Ok(n) if n.is_finite() => Ok(()),
_ => bail!("input `{name}` must be a finite number; got `{value}`"),
},
Self::Bool => {
if !is_bool(value) {
bail!(
"input `{name}` must be a boolean (true/false, 1/0, yes/no, on/off); \
got `{value}`"
);
}
Ok(())
}
Self::Path => check_path_shape(name, value),
Self::File => check_file(name, value, cwd),
Self::Dir => check_dir(name, value, cwd),
Self::Port => {
let ok = value
.parse::<u32>()
.ok()
.is_some_and(|n| (1..=65535).contains(&n));
if !ok {
bail!("input `{name}` must be a port in 1..=65535; got `{value}`");
}
Ok(())
}
Self::Pid => {
let ok = value.parse::<u32>().ok().is_some_and(|n| n >= 1);
if !ok {
bail!("input `{name}` must be a process id (>= 1); got `{value}`");
}
Ok(())
}
Self::Email => {
if !is_email(value) {
bail!("input `{name}` must be an email address; got `{value}`");
}
Ok(())
}
Self::Hostname => {
if !is_hostname(value) {
bail!("input `{name}` must be a hostname; got `{value}`");
}
Ok(())
}
Self::Ipv4 => {
if value.parse::<std::net::Ipv4Addr>().is_err() {
bail!("input `{name}` must be an IPv4 address; got `{value}`");
}
Ok(())
}
Self::Ipv6 => {
let s = value
.strip_prefix('[')
.and_then(|x| x.strip_suffix(']'))
.unwrap_or(value);
match s.parse::<IpAddr>() {
Ok(IpAddr::V6(_)) => Ok(()),
_ => bail!("input `{name}` must be an IPv6 address; got `{value}`"),
}
}
Self::Ip => {
let s = value
.strip_prefix('[')
.and_then(|x| x.strip_suffix(']'))
.unwrap_or(value);
if s.parse::<IpAddr>().is_err() {
bail!("input `{name}` must be an IP address; got `{value}`");
}
Ok(())
}
Self::Uuid => {
if !is_uuid(value) {
bail!("input `{name}` must be a UUID; got `{value}`");
}
Ok(())
}
Self::Hex => {
if !is_hex(value) {
bail!("input `{name}` must be hexadecimal; got `{value}`");
}
Ok(())
}
Self::Duration => check_duration(name, value),
Self::Date => {
if chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_err() {
bail!("input `{name}` must be a date (YYYY-MM-DD); got `{value}`");
}
Ok(())
}
Self::Json => {
if serde_json::from_str::<serde_json::Value>(value).is_err() {
bail!("input `{name}` must be valid JSON; got `{value}`");
}
Ok(())
}
}
}
}
impl<'de> Deserialize<'de> for InputType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::from_name(&s).map_err(de::Error::custom)
}
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct InputDef {
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
pub default: Option<String>,
#[serde(default, rename = "type")]
pub kind: InputType,
#[serde(default)]
pub choices: Vec<String>,
}
impl InputDef {
pub fn validate_name(name: &str) -> Result<()> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
bail!("input name must not be empty");
};
if !(first.is_ascii_alphabetic() || first == '_') {
bail!("input `{name}` must start with a letter or `_`");
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
bail!("input `{name}` may only contain letters, digits, `_`, and `-`");
}
Ok(())
}
pub fn validate(&self, name: &str) -> Result<()> {
Self::validate_name(name)?;
if self.kind == InputType::Enum && self.choices.is_empty() {
bail!("input `{name}`: type `enum` requires a non-empty `choices:` list");
}
for c in &self.choices {
if c.is_empty() {
bail!("input `{name}`: `choices` must not contain empty strings");
}
}
if let Some(d) = &self.default {
if !d.is_empty() {
self.check_value(name, d, None)
.map_err(|e| anyhow::anyhow!("input `{name}` default is invalid: {e}"))?;
}
}
Ok(())
}
pub fn check_value(&self, name: &str, value: &str, cwd: Option<&Path>) -> Result<()> {
self.kind.check(name, value, cwd)?;
if !self.choices.is_empty() && !self.choices.iter().any(|c| c == value) {
let listed = self
.choices
.iter()
.map(|c| format!("`{c}`"))
.collect::<Vec<_>>()
.join(", ");
bail!("input `{name}` must be one of {listed}; got `{value}`");
}
Ok(())
}
}
fn check_url(name: &str, value: &str, https_only: bool) -> Result<()> {
let parsed = match Url::parse(value) {
Ok(u) => u,
Err(_) => {
let want = if https_only {
"an absolute https URL"
} else {
"an absolute http or https URL"
};
bail!("input `{name}` must be {want}; got `{value}`");
}
};
let scheme = parsed.scheme();
if https_only {
if scheme != "https" {
bail!("input `{name}` must be an https URL; got `{value}`");
}
} else if scheme != "http" && scheme != "https" {
bail!("input `{name}` must be an http or https URL; got `{value}`");
}
if parsed.host_str().map(str::is_empty).unwrap_or(true) {
bail!("input `{name}` must include a host; got `{value}`");
}
Ok(())
}
fn check_path_shape(name: &str, value: &str) -> Result<()> {
if value.is_empty() || value.contains('\0') {
bail!("input `{name}` must be a path; got `{value}`");
}
Ok(())
}
fn expand_user_path(raw: &str) -> PathBuf {
if raw == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
}
if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(raw)
}
fn resolve_against_cwd(p: PathBuf, cwd: Option<&Path>) -> PathBuf {
if p.is_absolute() {
p
} else if let Some(cwd) = cwd {
cwd.join(p)
} else {
p
}
}
fn check_file(name: &str, value: &str, cwd: Option<&Path>) -> Result<()> {
check_path_shape(name, value)?;
if value == "-" {
return Ok(());
}
if cwd.is_none() {
return Ok(());
}
let path = resolve_against_cwd(expand_user_path(value), cwd);
if !path.is_file() {
bail!("input `{name}` must be an existing file (or `-` for stdin); got `{value}`");
}
Ok(())
}
fn check_dir(name: &str, value: &str, cwd: Option<&Path>) -> Result<()> {
check_path_shape(name, value)?;
if cwd.is_none() {
return Ok(());
}
let path = resolve_against_cwd(expand_user_path(value), cwd);
if !path.is_dir() {
bail!("input `{name}` must be an existing directory; got `{value}`");
}
Ok(())
}
fn is_bool(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off"
)
}
fn is_email(value: &str) -> bool {
let Some((local, domain)) = value.split_once('@') else {
return false;
};
if local.is_empty() || domain.is_empty() {
return false;
}
if local.contains(char::is_whitespace) || domain.contains(char::is_whitespace) {
return false;
}
if local.contains('@') || !domain.contains('.') {
return false;
}
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
return false;
}
true
}
fn is_hostname(value: &str) -> bool {
let s = value.strip_suffix('.').unwrap_or(value);
if s.is_empty() || s.len() > 253 {
return false;
}
s.split('.').all(|label| {
let b = label.as_bytes();
(1..=63).contains(&b.len())
&& b[0].is_ascii_alphanumeric()
&& b[b.len() - 1].is_ascii_alphanumeric()
&& b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
})
}
fn is_uuid(value: &str) -> bool {
if value.len() == 32 && value.bytes().all(|c| c.is_ascii_hexdigit()) {
return true;
}
let parts: Vec<&str> = value.split('-').collect();
parts.len() == 5
&& parts[0].len() == 8
&& parts[1].len() == 4
&& parts[2].len() == 4
&& parts[3].len() == 4
&& parts[4].len() == 12
&& parts
.iter()
.all(|p| p.bytes().all(|c| c.is_ascii_hexdigit()))
}
fn is_hex(value: &str) -> bool {
let digits = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.unwrap_or(value);
!digits.is_empty() && digits.bytes().all(|c| c.is_ascii_hexdigit())
}
fn check_duration(name: &str, value: &str) -> Result<()> {
let bytes = value.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_digit() {
bail!(
"input `{name}` must be a duration like `30`, `30s`, `5m`, `1h`, or `2d`; got `{value}`"
);
}
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
let frac = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == frac {
bail!(
"input `{name}` must be a duration like `30`, `30s`, `5m`, `1h`, or `2d`; got `{value}`"
);
}
}
let unit = value[i..].to_ascii_lowercase();
match unit.as_str() {
"" | "ns" | "us" | "µs" | "ms" | "s" | "m" | "h" | "d" => Ok(()),
_ => bail!(
"input `{name}` must be a duration like `30`, `30s`, `5m`, `1h`, or `2d`; got `{value}`"
),
}
}
pub fn collect_chain_inputs(chain: &[String], spec: &RootSpec) -> BTreeMap<String, InputDef> {
let mut out = BTreeMap::new();
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
for (name, def) in &node.inputs {
out.insert(name.clone(), def.clone());
}
map = &node.commands;
}
out
}
pub fn resolve_inputs(
defs: &BTreeMap<String, InputDef>,
trailing: &[OsString],
cwd: Option<&Path>,
) -> Result<(BTreeMap<String, String>, Vec<OsString>)> {
for (name, def) in defs {
def.validate(name)?;
}
if defs.is_empty() {
return Ok((BTreeMap::new(), trailing.to_vec()));
}
let mut provided: BTreeMap<String, String> = BTreeMap::new();
let mut rest: Vec<OsString> = Vec::new();
let mut i = 0usize;
while i < trailing.len() {
let raw = trailing[i].to_string_lossy();
if raw == "--" {
rest.extend_from_slice(&trailing[i + 1..]);
break;
}
if let Some(body) = raw.strip_prefix("--") {
if body.is_empty() {
rest.push(trailing[i].clone());
i += 1;
continue;
}
let (name, value_opt) = if let Some((n, v)) = body.split_once('=') {
(n.to_string(), Some(v.to_string()))
} else {
(body.to_string(), None)
};
if !defs.contains_key(&name) {
rest.push(trailing[i].clone());
i += 1;
continue;
}
let value = match value_opt {
Some(v) => v,
None => {
i += 1;
let Some(next) = trailing.get(i) else {
bail!("missing value for input flag `--{name}`");
};
let next_s = next.to_string_lossy();
if next_s.starts_with('-') && next_s != "-" {
bail!("missing value for input flag `--{name}`");
}
next_s.into_owned()
}
};
provided.insert(name, value);
i += 1;
continue;
}
rest.push(trailing[i].clone());
i += 1;
}
let mut resolved = BTreeMap::new();
let mut missing = Vec::new();
for (name, def) in defs {
if let Some(v) = provided.get(name) {
resolved.insert(name.clone(), v.clone());
continue;
}
if let Some(default) = &def.default {
resolved.insert(name.clone(), default.clone());
continue;
}
if def.required {
missing.push(format!("--{name}"));
}
}
if !missing.is_empty() {
bail!(
"missing required input(s): {} (see `--help` on this command)",
missing.join(", ")
);
}
for (name, value) in &resolved {
let def = &defs[name];
if value.is_empty() {
if def.required {
bail!("input `{name}` must not be empty");
}
continue;
}
def.check_value(name, value, cwd)?;
}
Ok((resolved, rest))
}
pub fn interpolate(template: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while !rest.is_empty() {
if let Some(start) = rest.find("${{") {
out.push_str(&rest[..start]);
let after = &rest[start + 3..];
let Some(end) = after.find("}}") else {
bail!("unclosed `${{{{` expression in `{template}`");
};
let inner = after[..end].trim();
out.push_str(&eval_expr(inner, inputs)?);
rest = &after[end + 2..];
} else {
out.push_str(rest);
break;
}
}
Ok(out)
}
fn eval_expr(inner: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
let Some(name) = inner.strip_prefix("inputs.") else {
bail!(
"unsupported expression `${{{{ {inner} }}}}` \
(only `inputs.<name>` is supported)"
);
};
let name = name.trim();
InputDef::validate_name(name)?;
inputs.get(name).cloned().ok_or_else(|| {
anyhow::anyhow!(
"expression refers to unknown or unset input `{name}` \
(declare it under `inputs:` and pass `--{name}` or set a default)"
)
})
}
pub fn format_inputs_help(defs: &BTreeMap<String, InputDef>) -> String {
if defs.is_empty() {
return String::new();
}
let mut out = String::from("Inputs:\n");
for (name, def) in defs {
let placeholder = if !def.choices.is_empty() {
def.choices.join("|")
} else {
def.kind.placeholder().to_string()
};
let mut line = format!(" --{name} <{placeholder}>");
let mut notes = Vec::new();
if def.kind != InputType::String {
notes.push(format!("type: {}", def.kind.as_str()));
}
if def.required && def.default.is_none() {
notes.push("required".to_string());
}
if let Some(d) = &def.default {
notes.push(format!("default: {d}"));
}
if !def.description.trim().is_empty() {
if notes.is_empty() {
line.push_str(&format!(" {}", def.description.trim()));
} else {
line.push_str(&format!(
" {} ({})",
def.description.trim(),
notes.join("; ")
));
}
} else if !notes.is_empty() {
line.push_str(&format!(" ({})", notes.join("; ")));
}
out.push_str(&line);
out.push('\n');
}
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn resolve_required_and_default() {
let defs = BTreeMap::from([
(
"path".into(),
InputDef {
description: "src".into(),
required: true,
default: None,
..Default::default()
},
),
(
"dest".into(),
InputDef {
description: String::new(),
required: false,
default: Some("~/Backups".into()),
..Default::default()
},
),
]);
let trailing = vec![OsString::from("--path"), OsString::from("/tmp/a")];
let (vals, rest) = resolve_inputs(&defs, &trailing, None).unwrap();
assert!(rest.is_empty());
assert_eq!(vals.get("path").map(String::as_str), Some("/tmp/a"));
assert_eq!(vals.get("dest").map(String::as_str), Some("~/Backups"));
}
#[test]
fn resolve_equals_and_passthrough_rest() {
let defs = BTreeMap::from([(
"path".into(),
InputDef {
required: true,
..Default::default()
},
)]);
let trailing = vec![
OsString::from("--path=/tmp"),
OsString::from("--"),
OsString::from("--verbose"),
];
let (vals, rest) = resolve_inputs(&defs, &trailing, None).unwrap();
assert_eq!(vals["path"], "/tmp");
assert_eq!(rest, vec![OsString::from("--verbose")]);
}
#[test]
fn interpolate_inputs() {
let inputs = BTreeMap::from([("path".into(), "/data".into())]);
assert_eq!(
interpolate("rsync ${{ inputs.path }}/", &inputs).unwrap(),
"rsync /data/"
);
}
#[test]
fn missing_required_errors() {
let defs = BTreeMap::from([(
"path".into(),
InputDef {
required: true,
..Default::default()
},
)]);
let err = resolve_inputs(&defs, &[], None).unwrap_err();
assert!(err.to_string().contains("missing required input"));
}
#[test]
fn type_check_table() {
let cases: &[(InputType, &str, bool)] = &[
(InputType::Url, "https://example.com/x", true),
(InputType::Url, "http://localhost:8080", true),
(InputType::Url, "ftp://example.com", false),
(InputType::Url, "not a url", false),
(InputType::Url, "/relative", false),
(InputType::HttpsUrl, "http://example.com", false),
(InputType::HttpsUrl, "https://example.com", true),
(InputType::Int, "-3", true),
(InputType::Int, "3.5", false),
(InputType::Uint, "0", true),
(InputType::Uint, "-1", false),
(InputType::Float, "1.5", true),
(InputType::Float, "nope", false),
(InputType::Bool, "yes", true),
(InputType::Bool, "maybe", false),
(InputType::Port, "443", true),
(InputType::Port, "0", false),
(InputType::Port, "65536", false),
(InputType::Pid, "1", true),
(InputType::Pid, "0", false),
(InputType::Email, "a@b.co", true),
(InputType::Email, "nope", false),
(InputType::Hostname, "example.com", true),
(InputType::Hostname, "localhost", true),
(InputType::Hostname, "bad_host!", false),
(InputType::Ipv4, "127.0.0.1", true),
(InputType::Ipv4, "::1", false),
(InputType::Ipv6, "::1", true),
(InputType::Ipv6, "127.0.0.1", false),
(InputType::Ip, "::1", true),
(InputType::Ip, "[::1]", true),
(
InputType::Uuid,
"550e8400-e29b-41d4-a716-446655440000",
true,
),
(InputType::Uuid, "not-a-uuid", false),
(InputType::Hex, "0xdead", true),
(InputType::Hex, "zz", false),
(InputType::Duration, "30s", true),
(InputType::Duration, "5m", true),
(InputType::Duration, "1h", true),
(InputType::Duration, "2d", true),
(InputType::Duration, "30", true),
(InputType::Duration, "nope", false),
(InputType::Date, "2026-08-14", true),
(InputType::Date, "14/08/2026", false),
(InputType::Json, "{\"a\":1}", true),
(InputType::Json, "{", false),
(InputType::Path, "/tmp/x", true),
(InputType::String, "anything", true),
];
for (ty, val, ok) in cases {
let r = ty.check("x", val, None);
assert_eq!(r.is_ok(), *ok, "{ty:?} `{val}` => {r:?}");
}
}
#[test]
fn file_and_dir_existence() {
let dir = tempdir().unwrap();
let file = dir.path().join("a.txt");
fs::write(&file, "hi").unwrap();
InputType::File
.check("f", file.to_str().unwrap(), Some(dir.path()))
.unwrap();
InputType::File.check("f", "-", Some(dir.path())).unwrap();
assert!(InputType::File
.check("f", "missing.txt", Some(dir.path()))
.is_err());
InputType::Dir
.check("d", dir.path().to_str().unwrap(), Some(dir.path()))
.unwrap();
assert!(InputType::Dir
.check("d", file.to_str().unwrap(), Some(dir.path()))
.is_err());
}
#[test]
fn resolve_rejects_bad_url_before_ok() {
let defs = BTreeMap::from([(
"url".into(),
InputDef {
required: true,
kind: InputType::Url,
..Default::default()
},
)]);
let err = resolve_inputs(
&defs,
&[OsString::from("--url"), OsString::from("not-a-url")],
None,
)
.unwrap_err();
assert!(err.to_string().contains("http or https URL"));
let (vals, _) = resolve_inputs(
&defs,
&[
OsString::from("--url"),
OsString::from("https://example.com/a"),
],
None,
)
.unwrap();
assert_eq!(vals["url"], "https://example.com/a");
}
#[test]
fn choices_and_empty_optional() {
let defs = BTreeMap::from([
(
"proto".into(),
InputDef {
default: Some("tcp".into()),
choices: vec!["tcp".into(), "udp".into()],
..Default::default()
},
),
(
"group".into(),
InputDef {
kind: InputType::Int,
default: Some(String::new()),
..Default::default()
},
),
]);
let (vals, _) = resolve_inputs(&defs, &[], None).unwrap();
assert_eq!(vals["proto"], "tcp");
assert_eq!(vals["group"], "");
let err = resolve_inputs(
&defs,
&[OsString::from("--proto"), OsString::from("sctp")],
None,
)
.unwrap_err();
assert!(err.to_string().contains("must be one of"));
}
#[test]
fn yaml_type_and_choices_deserialize() {
let def: InputDef = serde_yaml::from_str(
r#"
description: Absolute HTTPS URL to request
required: true
type: url
"#,
)
.unwrap();
assert_eq!(def.kind, InputType::Url);
assert!(def.required);
let proto: InputDef = serde_yaml::from_str(
r#"
default: tcp
choices: [tcp, udp]
"#,
)
.unwrap();
assert_eq!(proto.choices, vec!["tcp", "udp"]);
proto.validate("proto").unwrap();
let bad: Result<InputType, _> = serde_yaml::from_str("notatype");
assert!(bad.unwrap_err().to_string().contains("unknown input type"));
}
#[test]
fn validate_rejects_bad_default_and_enum_without_choices() {
let def = InputDef {
kind: InputType::Url,
default: Some("nope".into()),
..Default::default()
};
let err = def.validate("url").unwrap_err();
assert!(err.to_string().contains("default is invalid"));
let en = InputDef {
kind: InputType::Enum,
..Default::default()
};
assert!(en
.validate("mode")
.unwrap_err()
.to_string()
.contains("choices"));
}
#[test]
fn help_shows_type_and_choices() {
let defs = BTreeMap::from([(
"url".into(),
InputDef {
description: "Absolute HTTPS URL to request".into(),
required: true,
kind: InputType::Url,
..Default::default()
},
)]);
let help = format_inputs_help(&defs);
assert!(help.contains("--url <URL>"));
assert!(help.contains("type: url"));
assert!(help.contains("required"));
}
}