1use crate::error::{CliError, CliResult};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum ParamType {
23 #[default]
24 String,
25 Int,
26 Float,
27 Bool,
28}
29
30impl ParamType {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::String => "string",
34 Self::Int => "int",
35 Self::Float => "float",
36 Self::Bool => "bool",
37 }
38 }
39
40 pub fn placeholder(self) -> Value {
45 match self {
46 Self::String => Value::String("<param>".into()),
47 Self::Int => Value::from(0i64),
48 Self::Float => Value::from(0.0f64),
49 Self::Bool => Value::Bool(false),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56#[serde(deny_unknown_fields)]
57pub struct ParamSpec {
58 #[serde(rename = "type", default)]
61 pub kind: ParamType,
62
63 #[serde(default)]
67 pub required: bool,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub default: Option<Value>,
73
74 #[serde(default)]
78 pub secret: bool,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84}
85
86impl ParamSpec {
87 #[cfg(test)]
89 pub fn string_default(default: &str) -> Self {
90 Self {
91 kind: ParamType::String,
92 required: false,
93 default: Some(Value::String(default.into())),
94 secret: false,
95 description: None,
96 }
97 }
98}
99
100pub type ParamsSpec = BTreeMap<String, ParamSpec>;
103
104fn validate_name(name: &str) -> CliResult<()> {
108 let mut chars = name.chars();
109 let ok = match chars.next() {
110 Some(c) if c.is_ascii_alphabetic() || c == '_' => {
111 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
112 }
113 _ => false,
114 };
115 if !ok {
116 return Err(CliError::Config(format!(
117 "invalid param name '{name}' — names must match ^[A-Za-z_][A-Za-z0-9_]*$"
118 )));
119 }
120 Ok(())
121}
122
123fn default_matches(kind: ParamType, value: &Value) -> bool {
127 match kind {
128 ParamType::String => value.is_string() || value.is_number() || value.is_boolean(),
131 ParamType::Int => value.as_i64().is_some(),
132 ParamType::Float => value.as_f64().is_some(),
133 ParamType::Bool => value.is_boolean(),
134 }
135}
136
137pub fn validate(spec: &ParamsSpec) -> CliResult<()> {
140 for (name, p) in spec {
141 validate_name(name)?;
142 if p.required && p.default.is_some() {
143 return Err(CliError::Config(format!(
144 "param '{name}' is both `required: true` and has a `default` — a param with a \
145 default is optional; drop one"
146 )));
147 }
148 if let Some(d) = &p.default {
149 if d.is_null() {
150 return Err(CliError::Config(format!(
151 "param '{name}': `default: null` is not a value — omit `default` instead"
152 )));
153 }
154 if !default_matches(p.kind, d) {
155 return Err(CliError::Config(format!(
156 "param '{name}': default {d} is not a valid {} value",
157 p.kind.as_str()
158 )));
159 }
160 }
161 }
162 Ok(())
163}
164
165pub fn coerce(name: &str, kind: ParamType, value: &Value) -> CliResult<Value> {
172 let bad = |expected: &str| {
173 CliError::Config(format!(
174 "param '{name}': expected {expected}, got {}",
175 match value {
176 Value::Null => "null".to_string(),
177 other => other.to_string(),
178 }
179 ))
180 };
181 match kind {
182 ParamType::String => match value {
183 Value::String(s) => Ok(Value::String(s.clone())),
184 Value::Number(n) => Ok(Value::String(n.to_string())),
185 Value::Bool(b) => Ok(Value::String(b.to_string())),
186 _ => Err(bad("a string")),
187 },
188 ParamType::Int => match value {
189 Value::Number(n) => n.as_i64().map(Value::from).ok_or_else(|| bad("an integer")),
190 Value::String(s) => s
191 .trim()
192 .parse::<i64>()
193 .map(Value::from)
194 .map_err(|_| bad("an integer")),
195 _ => Err(bad("an integer")),
196 },
197 ParamType::Float => match value {
198 Value::Number(n) => n.as_f64().map(Value::from).ok_or_else(|| bad("a number")),
199 Value::String(s) => s
200 .trim()
201 .parse::<f64>()
202 .map(Value::from)
203 .map_err(|_| bad("a number")),
204 _ => Err(bad("a number")),
205 },
206 ParamType::Bool => match value {
207 Value::Bool(b) => Ok(Value::Bool(*b)),
208 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
209 "true" | "yes" | "1" => Ok(Value::Bool(true)),
210 "false" | "no" | "0" => Ok(Value::Bool(false)),
211 _ => Err(bad("a boolean (true/false)")),
212 },
213 _ => Err(bad("a boolean (true/false)")),
214 },
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde_json::json;
222
223 fn p(kind: ParamType, required: bool, default: Option<Value>) -> ParamSpec {
224 ParamSpec {
225 kind,
226 required,
227 default,
228 secret: false,
229 description: None,
230 }
231 }
232
233 #[test]
234 fn parses_block_with_defaults() {
235 let spec: ParamsSpec = serde_yaml::from_str(
236 "tenant_id: { type: string, required: true, description: Tenant }\n\
237 since: { default: \"1970-01-01\" }\n\
238 page_size: { type: int, default: 500 }\n\
239 api_token: { required: true, secret: true }\n",
240 )
241 .unwrap();
242 validate(&spec).unwrap();
243 assert_eq!(spec["tenant_id"].kind, ParamType::String);
244 assert!(spec["tenant_id"].required);
245 assert_eq!(spec["tenant_id"].description.as_deref(), Some("Tenant"));
246 assert_eq!(spec["since"].kind, ParamType::String);
248 assert_eq!(spec["page_size"].default, Some(json!(500)));
249 assert!(spec["api_token"].secret);
250 }
251
252 #[test]
253 fn rejects_unknown_field() {
254 let err = serde_yaml::from_str::<ParamsSpec>("a: { typo: 1 }").unwrap_err();
255 assert!(err.to_string().contains("typo"), "{err}");
256 }
257
258 #[test]
259 fn rejects_bad_names() {
260 for bad in ["", "1abc", "a.b", "a-b", "a b"] {
261 let spec: ParamsSpec = [(bad.to_string(), p(ParamType::String, false, None))].into();
262 assert!(validate(&spec).is_err(), "name {bad:?} should be rejected");
263 }
264 for good in ["a", "_a", "tenant_id", "A1"] {
265 let spec: ParamsSpec = [(good.to_string(), p(ParamType::String, false, None))].into();
266 validate(&spec).unwrap();
267 }
268 }
269
270 #[test]
271 fn rejects_required_with_default() {
272 let spec: ParamsSpec = [(
273 "a".to_string(),
274 p(ParamType::String, true, Some(json!("x"))),
275 )]
276 .into();
277 let err = validate(&spec).unwrap_err().to_string();
278 assert!(err.contains("required"), "{err}");
279 }
280
281 #[test]
282 fn rejects_null_and_mistyped_defaults() {
283 let spec: ParamsSpec = [(
284 "a".to_string(),
285 p(ParamType::String, false, Some(Value::Null)),
286 )]
287 .into();
288 assert!(validate(&spec).unwrap_err().to_string().contains("null"));
289
290 let spec: ParamsSpec = [(
291 "n".to_string(),
292 p(ParamType::Int, false, Some(json!("not-a-number"))),
293 )]
294 .into();
295 assert!(validate(&spec).unwrap_err().to_string().contains("int"));
296
297 let spec: ParamsSpec =
298 [("f".to_string(), p(ParamType::Bool, false, Some(json!(1))))].into();
299 assert!(validate(&spec).is_err());
300
301 let spec: ParamsSpec =
303 [("f".to_string(), p(ParamType::Float, false, Some(json!(1))))].into();
304 validate(&spec).unwrap();
305 }
306
307 #[test]
308 fn coerce_accepts_both_wire_shapes() {
309 assert_eq!(coerce("n", ParamType::Int, &json!(5)).unwrap(), json!(5));
311 assert_eq!(
312 coerce("f", ParamType::Float, &json!(1.5)).unwrap(),
313 json!(1.5)
314 );
315 assert_eq!(
316 coerce("b", ParamType::Bool, &json!(true)).unwrap(),
317 json!(true)
318 );
319 assert_eq!(coerce("n", ParamType::Int, &json!("5")).unwrap(), json!(5));
321 assert_eq!(
322 coerce("f", ParamType::Float, &json!(" 1.5 ")).unwrap(),
323 json!(1.5)
324 );
325 for truthy in ["true", "TRUE", "yes", "1"] {
326 assert_eq!(
327 coerce("b", ParamType::Bool, &json!(truthy)).unwrap(),
328 json!(true)
329 );
330 }
331 for falsy in ["false", "No", "0"] {
332 assert_eq!(
333 coerce("b", ParamType::Bool, &json!(falsy)).unwrap(),
334 json!(false)
335 );
336 }
337 assert_eq!(
339 coerce("s", ParamType::String, &json!(7)).unwrap(),
340 json!("7")
341 );
342 }
343
344 #[test]
345 fn coerce_rejects_type_errors_and_null() {
346 for (kind, v) in [
347 (ParamType::Int, json!(1.5)),
348 (ParamType::Int, json!("x")),
349 (ParamType::Int, json!(null)),
350 (ParamType::Float, json!("x")),
351 (ParamType::Bool, json!("maybe")),
352 (ParamType::Bool, json!(1)),
353 (ParamType::String, json!(null)),
354 (ParamType::String, json!({"a": 1})),
355 (ParamType::Int, json!([1])),
356 ] {
357 let err = coerce("p", kind, &v).unwrap_err().to_string();
358 assert!(err.contains("param 'p'"), "{kind:?} {v}: {err}");
359 }
360 }
361
362 #[test]
363 fn placeholders_are_type_shaped() {
364 assert!(ParamType::String.placeholder().is_string());
365 assert!(ParamType::Int.placeholder().is_i64());
366 assert!(ParamType::Float.placeholder().is_f64());
367 assert!(ParamType::Bool.placeholder().is_boolean());
368 assert_eq!(ParamType::Float.as_str(), "float");
369 }
370
371 #[test]
372 fn schema_generates() {
373 let schema = schemars::schema_for!(ParamSpec);
374 let v = serde_json::to_value(&schema).unwrap();
375 assert!(v["properties"]["type"].is_object());
376 assert!(v["properties"]["secret"].is_object());
377 }
378
379 #[test]
380 fn string_default_helper_builds_optional_param() {
381 let s = ParamSpec::string_default("v");
382 assert!(!s.required);
383 assert_eq!(s.default, Some(json!("v")));
384 }
385}