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 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub computed: Option<String>,
94}
95
96impl ParamSpec {
97 #[cfg(test)]
99 pub fn string_default(default: &str) -> Self {
100 Self {
101 kind: ParamType::String,
102 required: false,
103 default: Some(Value::String(default.into())),
104 secret: false,
105 description: None,
106 computed: None,
107 }
108 }
109}
110
111pub type ParamsSpec = BTreeMap<String, ParamSpec>;
114
115fn validate_name(name: &str) -> CliResult<()> {
119 let mut chars = name.chars();
120 let ok = match chars.next() {
121 Some(c) if c.is_ascii_alphabetic() || c == '_' => {
122 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
123 }
124 _ => false,
125 };
126 if !ok {
127 return Err(CliError::Config(format!(
128 "invalid param name '{name}' — names must match ^[A-Za-z_][A-Za-z0-9_]*$"
129 )));
130 }
131 Ok(())
132}
133
134fn default_matches(kind: ParamType, value: &Value) -> bool {
138 match kind {
139 ParamType::String => value.is_string() || value.is_number() || value.is_boolean(),
142 ParamType::Int => value.as_i64().is_some(),
143 ParamType::Float => value.as_f64().is_some(),
144 ParamType::Bool => value.is_boolean(),
145 }
146}
147
148pub fn validate(spec: &ParamsSpec) -> CliResult<()> {
151 for (name, p) in spec {
152 validate_name(name)?;
153 if p.computed.is_some() {
154 if p.required {
155 return Err(CliError::Config(format!(
156 "param '{name}' is `computed` and cannot be `required` — a computed param is \
157 derived, never supplied"
158 )));
159 }
160 if p.default.is_some() {
161 return Err(CliError::Config(format!(
162 "param '{name}' is `computed` and cannot have a `default` — its value is the \
163 computed expression"
164 )));
165 }
166 if p.secret {
167 return Err(CliError::Config(format!(
168 "param '{name}' is `computed` and cannot be `secret` — a derived value is not \
169 a secret source; reference the secret directly where it is used"
170 )));
171 }
172 }
173 if p.required && p.default.is_some() {
174 return Err(CliError::Config(format!(
175 "param '{name}' is both `required: true` and has a `default` — a param with a \
176 default is optional; drop one"
177 )));
178 }
179 if let Some(d) = &p.default {
180 if d.is_null() {
181 return Err(CliError::Config(format!(
182 "param '{name}': `default: null` is not a value — omit `default` instead"
183 )));
184 }
185 if !default_matches(p.kind, d) {
186 return Err(CliError::Config(format!(
187 "param '{name}': default {d} is not a valid {} value",
188 p.kind.as_str()
189 )));
190 }
191 }
192 }
193 Ok(())
194}
195
196pub fn coerce(name: &str, kind: ParamType, value: &Value) -> CliResult<Value> {
203 let bad = |expected: &str| {
204 CliError::Config(format!(
205 "param '{name}': expected {expected}, got {}",
206 match value {
207 Value::Null => "null".to_string(),
208 other => other.to_string(),
209 }
210 ))
211 };
212 match kind {
213 ParamType::String => match value {
214 Value::String(s) => Ok(Value::String(s.clone())),
215 Value::Number(n) => Ok(Value::String(n.to_string())),
216 Value::Bool(b) => Ok(Value::String(b.to_string())),
217 _ => Err(bad("a string")),
218 },
219 ParamType::Int => match value {
220 Value::Number(n) => n.as_i64().map(Value::from).ok_or_else(|| bad("an integer")),
221 Value::String(s) => s
222 .trim()
223 .parse::<i64>()
224 .map(Value::from)
225 .map_err(|_| bad("an integer")),
226 _ => Err(bad("an integer")),
227 },
228 ParamType::Float => match value {
229 Value::Number(n) => n.as_f64().map(Value::from).ok_or_else(|| bad("a number")),
230 Value::String(s) => s
231 .trim()
232 .parse::<f64>()
233 .map(Value::from)
234 .map_err(|_| bad("a number")),
235 _ => Err(bad("a number")),
236 },
237 ParamType::Bool => match value {
238 Value::Bool(b) => Ok(Value::Bool(*b)),
239 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
240 "true" | "yes" | "1" => Ok(Value::Bool(true)),
241 "false" | "no" | "0" => Ok(Value::Bool(false)),
242 _ => Err(bad("a boolean (true/false)")),
243 },
244 _ => Err(bad("a boolean (true/false)")),
245 },
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serde_json::json;
253
254 fn p(kind: ParamType, required: bool, default: Option<Value>) -> ParamSpec {
255 ParamSpec {
256 kind,
257 required,
258 default,
259 secret: false,
260 description: None,
261 computed: None,
262 }
263 }
264
265 #[test]
266 fn computed_param_cannot_be_required_default_or_secret() {
267 for yaml in [
268 "a: { computed: \"${param.x}\", required: true }\n",
269 "a: { computed: \"${param.x}\", default: y }\n",
270 "a: { computed: \"${param.x}\", secret: true }\n",
271 ] {
272 let spec: ParamsSpec = serde_yaml::from_str(yaml).unwrap();
273 assert!(
274 matches!(validate(&spec), Err(CliError::Config(m)) if m.contains("computed")),
275 "expected a computed-conflict error for: {yaml}"
276 );
277 }
278 let spec: ParamsSpec = serde_yaml::from_str("a: { computed: \"${param.x}\" }\n").unwrap();
280 assert!(validate(&spec).is_ok());
281 }
282
283 #[test]
284 fn parses_block_with_defaults() {
285 let spec: ParamsSpec = serde_yaml::from_str(
286 "tenant_id: { type: string, required: true, description: Tenant }\n\
287 since: { default: \"1970-01-01\" }\n\
288 page_size: { type: int, default: 500 }\n\
289 api_token: { required: true, secret: true }\n",
290 )
291 .unwrap();
292 validate(&spec).unwrap();
293 assert_eq!(spec["tenant_id"].kind, ParamType::String);
294 assert!(spec["tenant_id"].required);
295 assert_eq!(spec["tenant_id"].description.as_deref(), Some("Tenant"));
296 assert_eq!(spec["since"].kind, ParamType::String);
298 assert_eq!(spec["page_size"].default, Some(json!(500)));
299 assert!(spec["api_token"].secret);
300 }
301
302 #[test]
303 fn rejects_unknown_field() {
304 let err = serde_yaml::from_str::<ParamsSpec>("a: { typo: 1 }").unwrap_err();
305 assert!(err.to_string().contains("typo"), "{err}");
306 }
307
308 #[test]
309 fn rejects_bad_names() {
310 for bad in ["", "1abc", "a.b", "a-b", "a b"] {
311 let spec: ParamsSpec = [(bad.to_string(), p(ParamType::String, false, None))].into();
312 assert!(validate(&spec).is_err(), "name {bad:?} should be rejected");
313 }
314 for good in ["a", "_a", "tenant_id", "A1"] {
315 let spec: ParamsSpec = [(good.to_string(), p(ParamType::String, false, None))].into();
316 validate(&spec).unwrap();
317 }
318 }
319
320 #[test]
321 fn rejects_required_with_default() {
322 let spec: ParamsSpec = [(
323 "a".to_string(),
324 p(ParamType::String, true, Some(json!("x"))),
325 )]
326 .into();
327 let err = validate(&spec).unwrap_err().to_string();
328 assert!(err.contains("required"), "{err}");
329 }
330
331 #[test]
332 fn rejects_null_and_mistyped_defaults() {
333 let spec: ParamsSpec = [(
334 "a".to_string(),
335 p(ParamType::String, false, Some(Value::Null)),
336 )]
337 .into();
338 assert!(validate(&spec).unwrap_err().to_string().contains("null"));
339
340 let spec: ParamsSpec = [(
341 "n".to_string(),
342 p(ParamType::Int, false, Some(json!("not-a-number"))),
343 )]
344 .into();
345 assert!(validate(&spec).unwrap_err().to_string().contains("int"));
346
347 let spec: ParamsSpec =
348 [("f".to_string(), p(ParamType::Bool, false, Some(json!(1))))].into();
349 assert!(validate(&spec).is_err());
350
351 let spec: ParamsSpec =
353 [("f".to_string(), p(ParamType::Float, false, Some(json!(1))))].into();
354 validate(&spec).unwrap();
355 }
356
357 #[test]
358 fn coerce_accepts_both_wire_shapes() {
359 assert_eq!(coerce("n", ParamType::Int, &json!(5)).unwrap(), json!(5));
361 assert_eq!(
362 coerce("f", ParamType::Float, &json!(1.5)).unwrap(),
363 json!(1.5)
364 );
365 assert_eq!(
366 coerce("b", ParamType::Bool, &json!(true)).unwrap(),
367 json!(true)
368 );
369 assert_eq!(coerce("n", ParamType::Int, &json!("5")).unwrap(), json!(5));
371 assert_eq!(
372 coerce("f", ParamType::Float, &json!(" 1.5 ")).unwrap(),
373 json!(1.5)
374 );
375 for truthy in ["true", "TRUE", "yes", "1"] {
376 assert_eq!(
377 coerce("b", ParamType::Bool, &json!(truthy)).unwrap(),
378 json!(true)
379 );
380 }
381 for falsy in ["false", "No", "0"] {
382 assert_eq!(
383 coerce("b", ParamType::Bool, &json!(falsy)).unwrap(),
384 json!(false)
385 );
386 }
387 assert_eq!(
389 coerce("s", ParamType::String, &json!(7)).unwrap(),
390 json!("7")
391 );
392 }
393
394 #[test]
395 fn coerce_rejects_type_errors_and_null() {
396 for (kind, v) in [
397 (ParamType::Int, json!(1.5)),
398 (ParamType::Int, json!("x")),
399 (ParamType::Int, json!(null)),
400 (ParamType::Float, json!("x")),
401 (ParamType::Bool, json!("maybe")),
402 (ParamType::Bool, json!(1)),
403 (ParamType::String, json!(null)),
404 (ParamType::String, json!({"a": 1})),
405 (ParamType::Int, json!([1])),
406 ] {
407 let err = coerce("p", kind, &v).unwrap_err().to_string();
408 assert!(err.contains("param 'p'"), "{kind:?} {v}: {err}");
409 }
410 }
411
412 #[test]
413 fn placeholders_are_type_shaped() {
414 assert!(ParamType::String.placeholder().is_string());
415 assert!(ParamType::Int.placeholder().is_i64());
416 assert!(ParamType::Float.placeholder().is_f64());
417 assert!(ParamType::Bool.placeholder().is_boolean());
418 assert_eq!(ParamType::Float.as_str(), "float");
419 }
420
421 #[test]
422 fn schema_generates() {
423 let schema = schemars::schema_for!(ParamSpec);
424 let v = serde_json::to_value(&schema).unwrap();
425 assert!(v["properties"]["type"].is_object());
426 assert!(v["properties"]["secret"].is_object());
427 }
428
429 #[test]
430 fn string_default_helper_builds_optional_param() {
431 let s = ParamSpec::string_default("v");
432 assert!(!s.required);
433 assert_eq!(s.default, Some(json!("v")));
434 }
435}