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
86impl ParamSpec {
87    /// A plain optional string param with the given default.
88    #[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
100/// The whole `params:` block — declaration order is irrelevant, so a sorted map
101/// keeps every rendering (schema, list output, audit) deterministic.
102pub type ParamsSpec = BTreeMap<String, ParamSpec>;
103
104/// A param name must be an identifier: `^[A-Za-z_][A-Za-z0-9_]*$`. Dots are
105/// excluded because `${param.a.b}` would be ambiguous with a nested lookup, and
106/// dashes because they read as arithmetic in some config editors.
107fn 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
123/// Whether `value` is an acceptable literal for `kind` (used for `default`,
124/// which is authored in the config and therefore held to the declared type
125/// rather than leniently coerced like a caller-supplied value).
126fn default_matches(kind: ParamType, value: &Value) -> bool {
127    match kind {
128        // A string default may legitimately still hold an unresolved
129        // interpolation token; anything scalar is accepted and stringified.
130        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
137/// Fail-fast validation of a whole `params:` block, run at every entry point
138/// that touches params (config load, template registration, trigger).
139pub 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
165/// Coerce a caller-supplied value to the declared type.
166///
167/// Deliberately lenient about *representation* (a CLI `--param n=5` and an HTTP
168/// `{"n": 5}` must behave identically) and strict about *type* (`int` never
169/// silently accepts `1.5`). Never accepts `null`: a param either has a value or
170/// falls back to its default.
171pub 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        // `type` defaults to string.
247        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        // An int default is a valid float.
302        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        // HTTP JSON shape.
310        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        // CLI `--param k=v` shape (always a string).
320        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        // A scalar into a string param stringifies.
338        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}