use crate::error::{CliError, CliResult};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParamType {
#[default]
String,
Int,
Float,
Bool,
}
impl ParamType {
pub fn as_str(self) -> &'static str {
match self {
Self::String => "string",
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
}
}
pub fn placeholder(self) -> Value {
match self {
Self::String => Value::String("<param>".into()),
Self::Int => Value::from(0i64),
Self::Float => Value::from(0.0f64),
Self::Bool => Value::Bool(false),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ParamSpec {
#[serde(rename = "type", default)]
pub kind: ParamType,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(default)]
pub secret: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl ParamSpec {
#[cfg(test)]
pub fn string_default(default: &str) -> Self {
Self {
kind: ParamType::String,
required: false,
default: Some(Value::String(default.into())),
secret: false,
description: None,
}
}
}
pub type ParamsSpec = BTreeMap<String, ParamSpec>;
fn validate_name(name: &str) -> CliResult<()> {
let mut chars = name.chars();
let ok = match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
};
if !ok {
return Err(CliError::Config(format!(
"invalid param name '{name}' — names must match ^[A-Za-z_][A-Za-z0-9_]*$"
)));
}
Ok(())
}
fn default_matches(kind: ParamType, value: &Value) -> bool {
match kind {
ParamType::String => value.is_string() || value.is_number() || value.is_boolean(),
ParamType::Int => value.as_i64().is_some(),
ParamType::Float => value.as_f64().is_some(),
ParamType::Bool => value.is_boolean(),
}
}
pub fn validate(spec: &ParamsSpec) -> CliResult<()> {
for (name, p) in spec {
validate_name(name)?;
if p.required && p.default.is_some() {
return Err(CliError::Config(format!(
"param '{name}' is both `required: true` and has a `default` — a param with a \
default is optional; drop one"
)));
}
if let Some(d) = &p.default {
if d.is_null() {
return Err(CliError::Config(format!(
"param '{name}': `default: null` is not a value — omit `default` instead"
)));
}
if !default_matches(p.kind, d) {
return Err(CliError::Config(format!(
"param '{name}': default {d} is not a valid {} value",
p.kind.as_str()
)));
}
}
}
Ok(())
}
pub fn coerce(name: &str, kind: ParamType, value: &Value) -> CliResult<Value> {
let bad = |expected: &str| {
CliError::Config(format!(
"param '{name}': expected {expected}, got {}",
match value {
Value::Null => "null".to_string(),
other => other.to_string(),
}
))
};
match kind {
ParamType::String => match value {
Value::String(s) => Ok(Value::String(s.clone())),
Value::Number(n) => Ok(Value::String(n.to_string())),
Value::Bool(b) => Ok(Value::String(b.to_string())),
_ => Err(bad("a string")),
},
ParamType::Int => match value {
Value::Number(n) => n.as_i64().map(Value::from).ok_or_else(|| bad("an integer")),
Value::String(s) => s
.trim()
.parse::<i64>()
.map(Value::from)
.map_err(|_| bad("an integer")),
_ => Err(bad("an integer")),
},
ParamType::Float => match value {
Value::Number(n) => n.as_f64().map(Value::from).ok_or_else(|| bad("a number")),
Value::String(s) => s
.trim()
.parse::<f64>()
.map(Value::from)
.map_err(|_| bad("a number")),
_ => Err(bad("a number")),
},
ParamType::Bool => match value {
Value::Bool(b) => Ok(Value::Bool(*b)),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" | "yes" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "0" => Ok(Value::Bool(false)),
_ => Err(bad("a boolean (true/false)")),
},
_ => Err(bad("a boolean (true/false)")),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn p(kind: ParamType, required: bool, default: Option<Value>) -> ParamSpec {
ParamSpec {
kind,
required,
default,
secret: false,
description: None,
}
}
#[test]
fn parses_block_with_defaults() {
let spec: ParamsSpec = serde_yaml::from_str(
"tenant_id: { type: string, required: true, description: Tenant }\n\
since: { default: \"1970-01-01\" }\n\
page_size: { type: int, default: 500 }\n\
api_token: { required: true, secret: true }\n",
)
.unwrap();
validate(&spec).unwrap();
assert_eq!(spec["tenant_id"].kind, ParamType::String);
assert!(spec["tenant_id"].required);
assert_eq!(spec["tenant_id"].description.as_deref(), Some("Tenant"));
assert_eq!(spec["since"].kind, ParamType::String);
assert_eq!(spec["page_size"].default, Some(json!(500)));
assert!(spec["api_token"].secret);
}
#[test]
fn rejects_unknown_field() {
let err = serde_yaml::from_str::<ParamsSpec>("a: { typo: 1 }").unwrap_err();
assert!(err.to_string().contains("typo"), "{err}");
}
#[test]
fn rejects_bad_names() {
for bad in ["", "1abc", "a.b", "a-b", "a b"] {
let spec: ParamsSpec = [(bad.to_string(), p(ParamType::String, false, None))].into();
assert!(validate(&spec).is_err(), "name {bad:?} should be rejected");
}
for good in ["a", "_a", "tenant_id", "A1"] {
let spec: ParamsSpec = [(good.to_string(), p(ParamType::String, false, None))].into();
validate(&spec).unwrap();
}
}
#[test]
fn rejects_required_with_default() {
let spec: ParamsSpec = [(
"a".to_string(),
p(ParamType::String, true, Some(json!("x"))),
)]
.into();
let err = validate(&spec).unwrap_err().to_string();
assert!(err.contains("required"), "{err}");
}
#[test]
fn rejects_null_and_mistyped_defaults() {
let spec: ParamsSpec = [(
"a".to_string(),
p(ParamType::String, false, Some(Value::Null)),
)]
.into();
assert!(validate(&spec).unwrap_err().to_string().contains("null"));
let spec: ParamsSpec = [(
"n".to_string(),
p(ParamType::Int, false, Some(json!("not-a-number"))),
)]
.into();
assert!(validate(&spec).unwrap_err().to_string().contains("int"));
let spec: ParamsSpec =
[("f".to_string(), p(ParamType::Bool, false, Some(json!(1))))].into();
assert!(validate(&spec).is_err());
let spec: ParamsSpec =
[("f".to_string(), p(ParamType::Float, false, Some(json!(1))))].into();
validate(&spec).unwrap();
}
#[test]
fn coerce_accepts_both_wire_shapes() {
assert_eq!(coerce("n", ParamType::Int, &json!(5)).unwrap(), json!(5));
assert_eq!(
coerce("f", ParamType::Float, &json!(1.5)).unwrap(),
json!(1.5)
);
assert_eq!(
coerce("b", ParamType::Bool, &json!(true)).unwrap(),
json!(true)
);
assert_eq!(coerce("n", ParamType::Int, &json!("5")).unwrap(), json!(5));
assert_eq!(
coerce("f", ParamType::Float, &json!(" 1.5 ")).unwrap(),
json!(1.5)
);
for truthy in ["true", "TRUE", "yes", "1"] {
assert_eq!(
coerce("b", ParamType::Bool, &json!(truthy)).unwrap(),
json!(true)
);
}
for falsy in ["false", "No", "0"] {
assert_eq!(
coerce("b", ParamType::Bool, &json!(falsy)).unwrap(),
json!(false)
);
}
assert_eq!(
coerce("s", ParamType::String, &json!(7)).unwrap(),
json!("7")
);
}
#[test]
fn coerce_rejects_type_errors_and_null() {
for (kind, v) in [
(ParamType::Int, json!(1.5)),
(ParamType::Int, json!("x")),
(ParamType::Int, json!(null)),
(ParamType::Float, json!("x")),
(ParamType::Bool, json!("maybe")),
(ParamType::Bool, json!(1)),
(ParamType::String, json!(null)),
(ParamType::String, json!({"a": 1})),
(ParamType::Int, json!([1])),
] {
let err = coerce("p", kind, &v).unwrap_err().to_string();
assert!(err.contains("param 'p'"), "{kind:?} {v}: {err}");
}
}
#[test]
fn placeholders_are_type_shaped() {
assert!(ParamType::String.placeholder().is_string());
assert!(ParamType::Int.placeholder().is_i64());
assert!(ParamType::Float.placeholder().is_f64());
assert!(ParamType::Bool.placeholder().is_boolean());
assert_eq!(ParamType::Float.as_str(), "float");
}
#[test]
fn schema_generates() {
let schema = schemars::schema_for!(ParamSpec);
let v = serde_json::to_value(&schema).unwrap();
assert!(v["properties"]["type"].is_object());
assert!(v["properties"]["secret"].is_object());
}
#[test]
fn string_default_helper_builds_optional_param() {
let s = ParamSpec::string_default("v");
assert!(!s.required);
assert_eq!(s.default, Some(json!("v")));
}
}