Skip to main content

faucet_cli/params/
spec.rs

1//! Serde types + validation for the top-level `params:` block (#444).
2//!
3//! `params:` declares a config's **trigger-time override surface**: a typed,
4//! named set of values a caller supplies when the pipeline is run (via
5//! `faucet run --param`, `faucet template run --param`, or
6//! `POST /v1/templates/{id}/runs`). Declared params are referenced in the
7//! config as `${param.NAME}` and bound by [`crate::params::bind`].
8//!
9//! Everything here is pure data + pure validation — no I/O, no interpolation.
10
11use crate::error::{CliError, CliResult};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17/// The scalar type a param carries. Params are deliberately scalar-only: a
18/// param substitutes into a config *value* position, and structured overrides
19/// are what named templates / matrix rows are for.
20#[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    /// A type-shaped stand-in used when validating a config whose required
41    /// params have no value yet (template registration, `faucet validate`).
42    /// Never reaches a real connector — registration/validation only checks
43    /// structure, it never builds a source or sink.
44    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/// One declared parameter.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56#[serde(deny_unknown_fields)]
57pub struct ParamSpec {
58    /// Value type. Governs coercion of the supplied value and the type
59    /// substituted when `${param.NAME}` is a config value's *entire* text.
60    #[serde(rename = "type", default)]
61    pub kind: ParamType,
62
63    /// When true the caller MUST supply a value; there is no fallback.
64    /// Mutually exclusive with `default` (a defaulted param is by definition
65    /// optional).
66    #[serde(default)]
67    pub required: bool,
68
69    /// Value used when the caller supplies none. Resolved like any other config
70    /// scalar first, so `default: "${env:SINCE}"` works.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub default: Option<Value>,
73
74    /// Marks the value as sensitive: it is registered with the redaction
75    /// registry the moment it is bound, so it never reaches logs, error
76    /// strings, API responses, or the audit log in clear.
77    #[serde(default)]
78    pub secret: bool,
79
80    /// Human-readable purpose, surfaced by `faucet template list/show`, the
81    /// MCP `get_template` tool, and `GET /v1/templates/{id}`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84
85    /// A **derived** value: this param is not user-supplied but computed from
86    /// other params via an interpolation expression — `${param.NAME}` and the
87    /// `${map:NAME|case=value|*=default}` lookup — resolved *after* the ordinary
88    /// params bind (#573). A computed param is excluded from the trigger surface
89    /// (supplying a value for it is an error) and is mutually exclusive with
90    /// `required`, `default`, and `secret`. Example:
91    /// `accounts_domain: { computed: "${map:region|ca=zohocloud|*=zoho}" }`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub computed: Option<String>,
94}
95
96impl ParamSpec {
97    /// A plain optional string param with the given default.
98    #[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
111/// The whole `params:` block — declaration order is irrelevant, so a sorted map
112/// keeps every rendering (schema, list output, audit) deterministic.
113pub type ParamsSpec = BTreeMap<String, ParamSpec>;
114
115/// A param name must be an identifier: `^[A-Za-z_][A-Za-z0-9_]*$`. Dots are
116/// excluded because `${param.a.b}` would be ambiguous with a nested lookup, and
117/// dashes because they read as arithmetic in some config editors.
118fn 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
134/// Whether `value` is an acceptable literal for `kind` (used for `default`,
135/// which is authored in the config and therefore held to the declared type
136/// rather than leniently coerced like a caller-supplied value).
137fn default_matches(kind: ParamType, value: &Value) -> bool {
138    match kind {
139        // A string default may legitimately still hold an unresolved
140        // interpolation token; anything scalar is accepted and stringified.
141        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
148/// Fail-fast validation of a whole `params:` block, run at every entry point
149/// that touches params (config load, template registration, trigger).
150pub 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
196/// Coerce a caller-supplied value to the declared type.
197///
198/// Deliberately lenient about *representation* (a CLI `--param n=5` and an HTTP
199/// `{"n": 5}` must behave identically) and strict about *type* (`int` never
200/// silently accepts `1.5`). Never accepts `null`: a param either has a value or
201/// falls back to its default.
202pub 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        // A plain computed param validates.
279        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        // `type` defaults to string.
297        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        // An int default is a valid float.
352        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        // HTTP JSON shape.
360        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        // CLI `--param k=v` shape (always a string).
370        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        // A scalar into a string param stringifies.
388        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}