use super::spec::{self, ParamsSpec};
use crate::error::{CliError, CliResult};
use crate::interpolate::{
Directive, classify_directive, iter_directives, rewrite, value_to_string,
};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const PARAMS_KEY: &str = "params";
pub const PARAM_ID: &str = "param";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindMode {
Strict,
Placeholder,
}
pub type SuppliedParams = BTreeMap<String, Value>;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BoundParams {
pub values: BTreeMap<String, Value>,
pub secret_names: BTreeSet<String>,
}
impl BoundParams {
pub fn redacted(&self) -> BTreeMap<String, Value> {
self.values
.iter()
.map(|(k, v)| {
if self.secret_names.contains(k) {
(k.clone(), Value::String("***".into()))
} else {
(k.clone(), v.clone())
}
})
.collect()
}
pub fn has_secrets(&self) -> bool {
!self.secret_names.is_empty()
}
}
pub fn declared(doc: &Value) -> CliResult<ParamsSpec> {
let Some(raw) = doc.get(PARAMS_KEY) else {
return Ok(ParamsSpec::new());
};
if raw.is_null() {
return Ok(ParamsSpec::new());
}
let parsed: ParamsSpec = serde_json::from_value(raw.clone())
.map_err(|e| CliError::Config(format!("invalid `params:` block: {e}")))?;
spec::validate(&parsed)?;
Ok(parsed)
}
pub fn resolve(
spec: &ParamsSpec,
supplied: &SuppliedParams,
mode: BindMode,
) -> CliResult<BoundParams> {
for name in supplied.keys() {
if !spec.contains_key(name) {
return Err(CliError::UnknownParam {
name: name.clone(),
known: spec.keys().cloned().collect(),
});
}
}
for (name, p) in spec {
if p.computed.is_some() && supplied.contains_key(name) {
return Err(CliError::Config(format!(
"param '{name}' is `computed` and cannot be supplied a value — it is derived from \
other params"
)));
}
}
let mut bound = BoundParams::default();
for (name, p) in spec {
if p.computed.is_some() {
continue;
}
let value = match supplied.get(name) {
Some(raw) => {
reject_directives(name, raw)?;
spec::coerce(name, p.kind, raw)?
}
None => match &p.default {
Some(d) => spec::coerce(name, p.kind, d)?,
None => match mode {
BindMode::Placeholder => p.kind.placeholder(),
BindMode::Strict => {
return Err(CliError::MissingParam {
name: name.clone(),
description: p.description.clone(),
});
}
},
},
};
if p.secret {
crate::secrets::registry::register(&value_to_string(&value));
bound.secret_names.insert(name.clone());
}
bound.values.insert(name.clone(), value);
}
resolve_computed(spec, &mut bound)?;
Ok(bound)
}
fn resolve_computed(spec: &ParamsSpec, bound: &mut BoundParams) -> CliResult<()> {
let computed_names: BTreeSet<String> = spec
.iter()
.filter(|(_, p)| p.computed.is_some())
.map(|(n, _)| n.clone())
.collect();
let mut remaining: Vec<(String, String)> = spec
.iter()
.filter_map(|(n, p)| p.computed.as_ref().map(|c| (n.clone(), c.clone())))
.collect();
while !remaining.is_empty() {
let mut progressed = false;
let mut still = Vec::new();
for (name, expr) in remaining {
let refs = referenced_params(&expr);
if refs.iter().all(|r| bound.values.contains_key(r)) {
let value = eval_computed_expr(&name, &expr, &bound.values)?;
bound.values.insert(name, Value::String(value));
progressed = true;
} else {
still.push((name, expr));
}
}
if !progressed {
for (name, expr) in &still {
for r in referenced_params(expr) {
if !bound.values.contains_key(&r) && !computed_names.contains(&r) {
return Err(CliError::UnknownParamRef {
name: r,
token: format!("computed param '{name}'"),
});
}
}
}
let mut chain: Vec<String> = still.into_iter().map(|(n, _)| n).collect();
chain.sort();
return Err(CliError::InterpolationCycle { chain });
}
remaining = still;
}
Ok(())
}
fn referenced_params(expr: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = expr;
while let Some(start) = rest.find("${") {
let after = &rest[start + 2..];
let Some(end) = after.find('}') else { break };
let body = &after[..end];
if let Some(name) = body.strip_prefix("param.") {
out.push(name.trim().to_string());
} else if let Some(spec) = body.strip_prefix("map:")
&& let Some(input) = spec.split('|').next()
{
out.push(input.trim().to_string());
}
rest = &after[end + 1..];
}
out
}
fn eval_computed_expr(
name: &str,
expr: &str,
bound: &BTreeMap<String, Value>,
) -> CliResult<String> {
let mut out = String::new();
let mut rest = expr;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let end = after.find('}').ok_or_else(|| {
CliError::Config(format!(
"computed param '{name}': unterminated `${{` in expression '{expr}'"
))
})?;
let body = &after[..end];
out.push_str(&resolve_computed_token(name, body, bound)?);
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(out)
}
fn resolve_computed_token(
owner: &str,
body: &str,
bound: &BTreeMap<String, Value>,
) -> CliResult<String> {
if let Some(pname) = body.strip_prefix("param.") {
let pname = pname.trim();
let v = bound.get(pname).ok_or_else(|| CliError::UnknownParamRef {
name: pname.to_string(),
token: format!("computed param '{owner}'"),
})?;
return Ok(value_to_string(v));
}
if let Some(spec) = body.strip_prefix("map:") {
return resolve_map(owner, spec, bound);
}
Err(CliError::Config(format!(
"computed param '{owner}': `${{{body}}}` is not allowed — a computed expression may only \
reference `${{param.NAME}}` or `${{map:NAME|case=value|*=default}}`"
)))
}
fn resolve_map(owner: &str, spec: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
let mut parts = spec.split('|');
let input_name = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CliError::Config(format!(
"computed param '{owner}': `${{map:…}}` is missing the switch param name — write \
`${{map:NAME|case=value|*=default}}`"
))
})?;
let input = bound
.get(input_name)
.ok_or_else(|| CliError::UnknownParamRef {
name: input_name.to_string(),
token: format!("computed param '{owner}'"),
})?;
let input_str = value_to_string(input);
let mut default: Option<String> = None;
let mut matched: Option<String> = None;
for pair in parts {
let (case, value) = pair.split_once('=').ok_or_else(|| {
CliError::Config(format!(
"computed param '{owner}': map case '{pair}' is not `case=value`"
))
})?;
let case = case.trim();
if case == "*" {
default = Some(value.to_string());
} else if case == input_str {
matched = Some(value.to_string());
}
}
matched.or(default).ok_or_else(|| {
CliError::Config(format!(
"computed param '{owner}': map has no case for '{input_name}' = '{input_str}' and no \
`*` default"
))
})
}
pub fn bind_document(
doc: &mut Value,
supplied: &SuppliedParams,
mode: BindMode,
) -> CliResult<BoundParams> {
let spec = declared(doc)?;
let bound = resolve(&spec, supplied, mode)?;
let stashed = doc.get_mut(PARAMS_KEY).map(std::mem::take);
let result = substitute(doc, &bound.values);
if let (Some(block), Some(map)) = (stashed, doc.as_object_mut()) {
map.insert(PARAMS_KEY.to_string(), block);
}
result?;
Ok(bound)
}
fn reject_directives(name: &str, raw: &Value) -> CliResult<()> {
if let Value::String(s) = raw
&& s.contains("${")
{
return Err(CliError::Config(format!(
"param '{name}': value contains an interpolation directive (`${{`). Param values are \
literal data — put the directive in the config's `params:` default or in the config \
body instead"
)));
}
Ok(())
}
fn substitute(v: &mut Value, bound: &BTreeMap<String, Value>) -> CliResult<()> {
if let Value::String(s) = v {
let replaced = match whole_token(s, bound)? {
Some(typed) => typed,
None => Value::String(rewrite_text(s, bound)?),
};
*v = replaced;
return Ok(());
}
match v {
Value::Array(items) => {
for item in items.iter_mut() {
substitute(item, bound)?;
}
}
Value::Object(map) => {
let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
for (key, mut val) in entries {
substitute(&mut val, bound)?;
map.insert(rewrite_text(&key, bound)?, val);
}
}
_ => {}
}
Ok(())
}
fn whole_token(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<Option<Value>> {
let mut tokens = iter_directives(s);
let Some((token, dir)) = tokens.next() else {
return Ok(None);
};
if tokens.next().is_some() || token != s {
return Ok(None);
}
match dir {
Directive::Deferred { id, path } if id == PARAM_ID => {
Ok(Some(lookup(path, token, bound)?.clone()))
}
Directive::LoadTime {
prefix: "map",
body,
} => Ok(Some(Value::String(resolve_map(token, body, bound)?))),
_ => Ok(None),
}
}
fn rewrite_text(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
rewrite(s, |body| match classify_directive(body) {
Directive::Deferred { id, path } if id == PARAM_ID => {
let token = format!("${{{body}}}");
Ok(Some(value_to_string(lookup(path, &token, bound)?)))
}
Directive::LoadTime {
prefix: "map",
body: map_body,
} => {
let token = format!("${{{body}}}");
Ok(Some(resolve_map(&token, map_body, bound)?))
}
_ => Ok(None),
})
}
fn lookup<'a>(path: &str, token: &str, bound: &'a BTreeMap<String, Value>) -> CliResult<&'a Value> {
if path.is_empty() {
return Err(CliError::Config(format!(
"interpolation '{token}' is missing a param name — write `${{param.NAME}}`"
)));
}
if path.contains('.') {
return Err(CliError::Config(format!(
"interpolation '{token}' is not a valid param reference — params are scalars, so \
`${{param.NAME}}` takes a bare name"
)));
}
bound.get(path).ok_or_else(|| CliError::UnknownParamRef {
name: path.to_string(),
token: token.to_string(),
})
}
pub fn parse_cli_param(arg: &str) -> CliResult<(String, Value)> {
let (key, value) = arg.split_once('=').ok_or_else(|| {
CliError::Config(format!("invalid --param '{arg}' — expected `name=value`"))
})?;
let key = key.trim();
if key.is_empty() {
return Err(CliError::Config(format!(
"invalid --param '{arg}' — the name is empty"
)));
}
Ok((key.to_string(), Value::String(value.to_string())))
}
pub fn collect_cli_params(args: &[String]) -> CliResult<SuppliedParams> {
let mut out = SuppliedParams::new();
for arg in args {
let (k, v) = parse_cli_param(arg)?;
if out.insert(k.clone(), v).is_some() {
return Err(CliError::Config(format!(
"--param '{k}' was given more than once"
)));
}
}
Ok(out)
}
pub fn collect_env_overrides(args: &[String]) -> CliResult<BTreeMap<String, String>> {
let mut out = BTreeMap::new();
for arg in args {
let (name, value) = match arg.split_once('=') {
Some((n, v)) => (n.trim().to_string(), v.to_string()),
None => {
let n = arg.trim().to_string();
let v = std::env::var(&n).map_err(|_| {
CliError::Config(format!(
"--param-env '{n}' has no value and '{n}' is not set in the environment"
))
})?;
(n, v)
}
};
if name.is_empty() {
return Err(CliError::Config(format!(
"invalid --param-env '{arg}' — the variable name is empty"
)));
}
if out.insert(name.clone(), value).is_some() {
return Err(CliError::Config(format!(
"--param-env '{name}' was given more than once"
)));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec_of(yaml: &str) -> ParamsSpec {
serde_yaml::from_str(yaml).unwrap()
}
fn supplied(pairs: &[(&str, Value)]) -> SuppliedParams {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn resolves_supplied_default_and_placeholder() {
let spec = spec_of(
"tenant: { required: true }\n\
since: { default: \"1970-01-01\" }\n\
page: { type: int, required: true }\n",
);
let bound = resolve(
&spec,
&supplied(&[("tenant", json!("acme")), ("page", json!("50"))]),
BindMode::Strict,
)
.unwrap();
assert_eq!(bound.values["tenant"], json!("acme"));
assert_eq!(bound.values["since"], json!("1970-01-01"));
assert_eq!(bound.values["page"], json!(50));
let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
assert_eq!(bound.values["tenant"], json!("<param>"));
assert_eq!(bound.values["page"], json!(0));
assert_eq!(bound.values["since"], json!("1970-01-01"));
}
#[test]
fn computed_param_map_default_and_match() {
let spec = spec_of(
"region: { default: com }\n\
accounts_domain: { computed: \"${map:region|ca=zohocloud|*=zoho}\" }\n",
);
let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(bound.values["accounts_domain"], json!("zoho"));
assert_eq!(bound.values["region"], json!("com"));
let bound = resolve(
&spec,
&supplied(&[("region", json!("ca"))]),
BindMode::Strict,
)
.unwrap();
assert_eq!(bound.values["accounts_domain"], json!("zohocloud"));
}
#[test]
fn computed_param_rejected_when_supplied() {
let spec = spec_of(
"region: { default: com }\n\
accounts_domain: { computed: \"${map:region|*=zoho}\" }\n",
);
let err = resolve(
&spec,
&supplied(&[("accounts_domain", json!("hacked"))]),
BindMode::Strict,
)
.unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("computed") && m.contains("cannot be supplied")),
"got {err:?}"
);
}
#[test]
fn computed_param_chain_resolves_in_dependency_order() {
let spec = spec_of(
"env: { default: prod }\n\
a: { computed: \"${map:env|prod=live|*=test}\" }\n\
b: { computed: \"tier-${param.a}\" }\n",
);
let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(bound.values["a"], json!("live"));
assert_eq!(bound.values["b"], json!("tier-live"));
}
#[test]
fn computed_param_cycle_is_detected() {
let spec = spec_of(
"a: { computed: \"${param.b}\" }\n\
b: { computed: \"${param.a}\" }\n",
);
match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
CliError::InterpolationCycle { chain } => {
assert_eq!(chain, vec!["a".to_string(), "b".to_string()]);
}
other => panic!("expected InterpolationCycle, got {other:?}"),
}
}
#[test]
fn computed_param_unknown_reference_is_typed() {
let spec = spec_of("a: { computed: \"${param.nope}\" }\n");
match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
CliError::UnknownParamRef { name, .. } => assert_eq!(name, "nope"),
other => panic!("expected UnknownParamRef, got {other:?}"),
}
}
#[test]
fn map_with_no_match_and_no_default_errors() {
let spec = spec_of(
"region: { default: xx }\n\
d: { computed: \"${map:region|ca=zohocloud}\" }\n",
);
let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("no case for") && m.contains("no")),
"got {err:?}"
);
}
#[test]
fn computed_unterminated_brace_errors() {
let spec = spec_of("a: { computed: \"x-${param.region\" }\n");
let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("unterminated")),
"got {err:?}"
);
}
#[test]
fn computed_disallowed_directive_errors() {
let spec = spec_of("a: { computed: \"${env:SECRET}\" }\n");
let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("may only reference")),
"got {err:?}"
);
}
#[test]
fn map_case_not_key_value_errors() {
let spec = spec_of(
"region: { default: com }\n\
d: { computed: \"${map:region|badcase}\" }\n",
);
let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("is not `case=value`")),
"got {err:?}"
);
}
#[test]
fn standalone_map_missing_switch_name_errors() {
let mut doc = json!({
"pipeline": { "source": { "config": { "h": "${map:|a=b}" } } }
});
let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::Config(m) if m.contains("missing the switch param name")),
"got {err:?}"
);
}
#[test]
fn standalone_map_unknown_input_errors() {
let mut doc = json!({
"pipeline": { "source": { "config": { "h": "${map:nope|*=x}" } } }
});
let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(
matches!(&err, CliError::UnknownParamRef { name, .. } if name == "nope"),
"got {err:?}"
);
}
#[test]
fn whole_token_map_resolves_with_declared_type() {
let mut doc = json!({
"params": { "region": { "default": "com" } },
"pipeline": { "source": { "config": {
"domain": "${map:region|ca=zohocloud|*=zoho}"
} } }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc["pipeline"]["source"]["config"]["domain"], json!("zoho"));
}
#[test]
fn standalone_map_token_resolves_in_document() {
let mut doc = json!({
"params": { "region": { "default": "ca" } },
"pipeline": { "source": { "config": {
"host": "accounts.${map:region|ca=zohocloud|*=zoho}.${param.region}"
} } }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(
doc["pipeline"]["source"]["config"]["host"],
json!("accounts.zohocloud.ca")
);
}
#[test]
fn bind_document_substitutes_computed_param() {
let mut doc = json!({
"params": {
"region": { "default": "com" },
"accounts_domain": { "computed": "${map:region|ca=zohocloud|*=zoho}" }
},
"pipeline": { "source": { "config": {
"base_url": "https://accounts.${param.accounts_domain}.${param.region}"
} } }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(
doc["pipeline"]["source"]["config"]["base_url"],
json!("https://accounts.zoho.com")
);
assert_eq!(
doc["params"]["accounts_domain"]["computed"],
json!("${map:region|ca=zohocloud|*=zoho}")
);
}
#[test]
fn missing_required_param_is_a_typed_error() {
let spec = spec_of("tenant: { required: true, description: Tenant to sync }\n");
match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
CliError::MissingParam { name, description } => {
assert_eq!(name, "tenant");
assert_eq!(description.as_deref(), Some("Tenant to sync"));
}
other => panic!("expected MissingParam, got {other:?}"),
}
}
#[test]
fn unknown_supplied_param_is_rejected() {
let spec = spec_of("tenant: { required: true }\n");
match resolve(
&spec,
&supplied(&[("tenant", json!("a")), ("tenatn", json!("b"))]),
BindMode::Strict,
)
.unwrap_err()
{
CliError::UnknownParam { name, known } => {
assert_eq!(name, "tenatn");
assert_eq!(known, vec!["tenant".to_string()]);
}
other => panic!("expected UnknownParam, got {other:?}"),
}
}
#[test]
fn supplied_value_may_not_carry_a_directive() {
let spec = spec_of("t: { required: true }\n");
let err = resolve(
&spec,
&supplied(&[("t", json!("${vault:secret/data/db#password}"))]),
BindMode::Strict,
)
.unwrap_err()
.to_string();
assert!(err.contains("literal data"), "{err}");
}
#[test]
fn binds_document_typed_and_textual() {
let mut doc = json!({
"version": 1,
"params": {
"tenant": { "required": true },
"page": { "type": "int", "default": 500 },
"live": { "type": "bool", "default": true }
},
"pipeline": {
"source": {
"type": "rest",
"config": {
"url": "https://api.example.com/${param.tenant}/events",
"page_size": "${param.page}",
"streaming": "${param.live}"
}
}
}
});
let bound = bind_document(
&mut doc,
&supplied(&[("tenant", json!("acme"))]),
BindMode::Strict,
)
.unwrap();
let cfg = &doc["pipeline"]["source"]["config"];
assert_eq!(cfg["url"], "https://api.example.com/acme/events");
assert_eq!(cfg["page_size"], json!(500));
assert_eq!(cfg["streaming"], json!(true));
assert_eq!(doc["params"]["page"]["default"], json!(500));
assert_eq!(bound.values["tenant"], json!("acme"));
}
#[test]
fn defaults_inside_the_params_block_are_not_substituted() {
let mut doc = json!({
"params": { "a": { "default": "${param.a}" } },
"pipeline": { "x": "ok" }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc["params"]["a"]["default"], "${param.a}");
}
#[test]
fn undeclared_reference_is_rejected() {
let mut doc = json!({
"params": { "a": { "default": "1" } },
"pipeline": { "url": "${param.b}" }
});
match bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
CliError::UnknownParamRef { name, token } => {
assert_eq!(name, "b");
assert_eq!(token, "${param.b}");
}
other => panic!("expected UnknownParamRef, got {other:?}"),
}
}
#[test]
fn reference_without_a_params_block_is_rejected() {
let mut doc = json!({ "pipeline": { "url": "${param.x}" } });
let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(matches!(err, CliError::UnknownParamRef { .. }), "{err:?}");
}
#[test]
fn supplying_a_param_with_no_block_is_rejected() {
let mut doc = json!({ "pipeline": {} });
match bind_document(&mut doc, &supplied(&[("x", json!("1"))]), BindMode::Strict)
.unwrap_err()
{
CliError::UnknownParam { known, .. } => assert!(known.is_empty()),
other => panic!("expected UnknownParam, got {other:?}"),
}
}
#[test]
fn malformed_references_are_rejected() {
for bad in ["${param}", "${param.a.b}"] {
let mut doc = json!({
"params": { "a": { "default": "1" } },
"pipeline": { "url": bad }
});
let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict)
.unwrap_err()
.to_string();
assert!(err.contains("param"), "{bad}: {err}");
}
}
#[test]
fn escaped_token_stays_literal() {
let mut doc = json!({
"params": { "a": { "default": "v" } },
"pipeline": { "note": "$${param.a}" }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc["pipeline"]["note"], "${param.a}");
}
#[test]
fn other_namespaces_survive_binding() {
let mut doc = json!({
"params": { "a": { "default": "v" } },
"pipeline": { "url": "${param.a}/${now.date}/${users.id}" }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc["pipeline"]["url"], "v/${now.date}/${users.id}");
}
#[test]
fn substitutes_into_keys_and_arrays() {
let mut doc = json!({
"params": { "h": { "default": "X-Tenant" }, "n": { "type": "int", "default": 2 } },
"pipeline": {
"headers": { "${param.h}": "v" },
"list": ["${param.n}", "n=${param.n}"]
}
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc["pipeline"]["headers"]["X-Tenant"], "v");
assert_eq!(doc["pipeline"]["list"][0], json!(2));
assert_eq!(doc["pipeline"]["list"][1], json!("n=2"));
}
#[test]
fn secret_params_are_tracked_and_redacted() {
let spec = spec_of("token: { required: true, secret: true }\nuser: { default: bob }\n");
let bound = resolve(
&spec,
&supplied(&[("token", json!("s3cret-value-long-enough"))]),
BindMode::Strict,
)
.unwrap();
assert!(bound.has_secrets());
let red = bound.redacted();
assert_eq!(red["token"], json!("***"));
assert_eq!(red["user"], json!("bob"));
assert_eq!(
crate::secrets::registry::redact("token=s3cret-value-long-enough"),
"token=***"
);
}
#[test]
fn invalid_params_block_is_a_config_error() {
let doc = json!({ "params": { "a": { "type": "date" } } });
let err = declared(&doc).unwrap_err().to_string();
assert!(err.contains("`params:` block"), "{err}");
assert!(declared(&json!({ "params": null })).unwrap().is_empty());
assert!(declared(&json!({})).unwrap().is_empty());
}
#[test]
fn cli_param_parsing() {
let (k, v) = parse_cli_param("tenant=acme").unwrap();
assert_eq!(k, "tenant");
assert_eq!(v, json!("acme"));
let (_, v) = parse_cli_param("q=a=b").unwrap();
assert_eq!(v, json!("a=b"));
let (_, v) = parse_cli_param("q=").unwrap();
assert_eq!(v, json!(""));
assert!(parse_cli_param("noequals").is_err());
assert!(parse_cli_param("=v").is_err());
let map = collect_cli_params(&["a=1".into(), "b=2".into()]).unwrap();
assert_eq!(map.len(), 2);
let err = collect_cli_params(&["a=1".into(), "a=2".into()])
.unwrap_err()
.to_string();
assert!(err.contains("more than once"), "{err}");
}
#[test]
fn env_override_parsing() {
let map = collect_env_overrides(&["A=1".into()]).unwrap();
assert_eq!(map["A"], "1");
unsafe { std::env::set_var("FAUCET_PARAM_ENV_TEST", "from-env") };
let map = collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).unwrap();
assert_eq!(map["FAUCET_PARAM_ENV_TEST"], "from-env");
unsafe { std::env::remove_var("FAUCET_PARAM_ENV_TEST") };
assert!(collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).is_err());
assert!(collect_env_overrides(&["=1".into()]).is_err());
assert!(collect_env_overrides(&["A=1".into(), "A=2".into()]).is_err());
}
#[test]
fn placeholder_mode_leaves_a_bindable_document() {
let mut doc = json!({
"params": { "t": { "required": true }, "n": { "type": "int", "required": true } },
"pipeline": { "source": { "config": { "url": "https://x/${param.t}", "n": "${param.n}" } } }
});
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
let cfg = &doc["pipeline"]["source"]["config"];
assert_eq!(cfg["url"], "https://x/<param>");
assert_eq!(cfg["n"], json!(0));
}
#[test]
fn bound_params_default_is_empty() {
let b = BoundParams::default();
assert!(!b.has_secrets());
assert!(b.redacted().is_empty());
}
#[test]
fn binding_a_non_object_document_is_a_no_op() {
let mut doc = json!(["${param.a}"]);
let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
assert!(matches!(err, CliError::UnknownParamRef { .. }));
let mut doc = json!(7);
bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
assert_eq!(doc, json!(7));
}
}