1use super::spec::{self, ParamsSpec};
25use crate::error::{CliError, CliResult};
26use crate::interpolate::{
27 Directive, classify_directive, iter_directives, rewrite, value_to_string,
28};
29use serde_json::Value;
30use std::collections::{BTreeMap, BTreeSet};
31
32pub const PARAMS_KEY: &str = "params";
34
35pub const PARAM_ID: &str = "param";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum BindMode {
41 Strict,
44 Placeholder,
48}
49
50pub type SuppliedParams = BTreeMap<String, Value>;
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct BoundParams {
58 pub values: BTreeMap<String, Value>,
59 pub secret_names: BTreeSet<String>,
60}
61
62impl BoundParams {
63 pub fn redacted(&self) -> BTreeMap<String, Value> {
67 self.values
68 .iter()
69 .map(|(k, v)| {
70 if self.secret_names.contains(k) {
71 (k.clone(), Value::String("***".into()))
72 } else {
73 (k.clone(), v.clone())
74 }
75 })
76 .collect()
77 }
78
79 pub fn has_secrets(&self) -> bool {
82 !self.secret_names.is_empty()
83 }
84}
85
86pub fn declared(doc: &Value) -> CliResult<ParamsSpec> {
89 let Some(raw) = doc.get(PARAMS_KEY) else {
90 return Ok(ParamsSpec::new());
91 };
92 if raw.is_null() {
93 return Ok(ParamsSpec::new());
94 }
95 let parsed: ParamsSpec = serde_json::from_value(raw.clone())
96 .map_err(|e| CliError::Config(format!("invalid `params:` block: {e}")))?;
97 spec::validate(&parsed)?;
98 Ok(parsed)
99}
100
101pub fn resolve(
107 spec: &ParamsSpec,
108 supplied: &SuppliedParams,
109 mode: BindMode,
110) -> CliResult<BoundParams> {
111 for name in supplied.keys() {
112 if !spec.contains_key(name) {
113 return Err(CliError::UnknownParam {
114 name: name.clone(),
115 known: spec.keys().cloned().collect(),
116 });
117 }
118 }
119
120 for (name, p) in spec {
122 if p.computed.is_some() && supplied.contains_key(name) {
123 return Err(CliError::Config(format!(
124 "param '{name}' is `computed` and cannot be supplied a value — it is derived from \
125 other params"
126 )));
127 }
128 }
129
130 let mut bound = BoundParams::default();
131 for (name, p) in spec {
132 if p.computed.is_some() {
135 continue;
136 }
137 let value = match supplied.get(name) {
138 Some(raw) => {
139 reject_directives(name, raw)?;
140 spec::coerce(name, p.kind, raw)?
141 }
142 None => match &p.default {
143 Some(d) => spec::coerce(name, p.kind, d)?,
147 None => match mode {
148 BindMode::Placeholder => p.kind.placeholder(),
149 BindMode::Strict => {
150 return Err(CliError::MissingParam {
151 name: name.clone(),
152 description: p.description.clone(),
153 });
154 }
155 },
156 },
157 };
158 if p.secret {
159 crate::secrets::registry::register(&value_to_string(&value));
162 bound.secret_names.insert(name.clone());
163 }
164 bound.values.insert(name.clone(), value);
165 }
166
167 resolve_computed(spec, &mut bound)?;
168 Ok(bound)
169}
170
171fn resolve_computed(spec: &ParamsSpec, bound: &mut BoundParams) -> CliResult<()> {
180 let computed_names: BTreeSet<String> = spec
181 .iter()
182 .filter(|(_, p)| p.computed.is_some())
183 .map(|(n, _)| n.clone())
184 .collect();
185 let mut remaining: Vec<(String, String)> = spec
186 .iter()
187 .filter_map(|(n, p)| p.computed.as_ref().map(|c| (n.clone(), c.clone())))
188 .collect();
189
190 while !remaining.is_empty() {
191 let mut progressed = false;
192 let mut still = Vec::new();
193 for (name, expr) in remaining {
194 let refs = referenced_params(&expr);
195 if refs.iter().all(|r| bound.values.contains_key(r)) {
196 let value = eval_computed_expr(&name, &expr, &bound.values)?;
197 bound.values.insert(name, Value::String(value));
198 progressed = true;
199 } else {
200 still.push((name, expr));
201 }
202 }
203 if !progressed {
204 for (name, expr) in &still {
207 for r in referenced_params(expr) {
208 if !bound.values.contains_key(&r) && !computed_names.contains(&r) {
209 return Err(CliError::UnknownParamRef {
210 name: r,
211 token: format!("computed param '{name}'"),
212 });
213 }
214 }
215 }
216 let mut chain: Vec<String> = still.into_iter().map(|(n, _)| n).collect();
217 chain.sort();
218 return Err(CliError::InterpolationCycle { chain });
219 }
220 remaining = still;
221 }
222 Ok(())
223}
224
225fn referenced_params(expr: &str) -> Vec<String> {
228 let mut out = Vec::new();
229 let mut rest = expr;
230 while let Some(start) = rest.find("${") {
231 let after = &rest[start + 2..];
232 let Some(end) = after.find('}') else { break };
233 let body = &after[..end];
234 if let Some(name) = body.strip_prefix("param.") {
235 out.push(name.trim().to_string());
236 } else if let Some(spec) = body.strip_prefix("map:")
237 && let Some(input) = spec.split('|').next()
238 {
239 out.push(input.trim().to_string());
240 }
241 rest = &after[end + 1..];
242 }
243 out
244}
245
246fn eval_computed_expr(
251 name: &str,
252 expr: &str,
253 bound: &BTreeMap<String, Value>,
254) -> CliResult<String> {
255 let mut out = String::new();
256 let mut rest = expr;
257 while let Some(start) = rest.find("${") {
258 out.push_str(&rest[..start]);
259 let after = &rest[start + 2..];
260 let end = after.find('}').ok_or_else(|| {
261 CliError::Config(format!(
262 "computed param '{name}': unterminated `${{` in expression '{expr}'"
263 ))
264 })?;
265 let body = &after[..end];
266 out.push_str(&resolve_computed_token(name, body, bound)?);
267 rest = &after[end + 1..];
268 }
269 out.push_str(rest);
270 Ok(out)
271}
272
273fn resolve_computed_token(
275 owner: &str,
276 body: &str,
277 bound: &BTreeMap<String, Value>,
278) -> CliResult<String> {
279 if let Some(pname) = body.strip_prefix("param.") {
280 let pname = pname.trim();
281 let v = bound.get(pname).ok_or_else(|| CliError::UnknownParamRef {
282 name: pname.to_string(),
283 token: format!("computed param '{owner}'"),
284 })?;
285 return Ok(value_to_string(v));
286 }
287 if let Some(spec) = body.strip_prefix("map:") {
288 return resolve_map(owner, spec, bound);
289 }
290 Err(CliError::Config(format!(
291 "computed param '{owner}': `${{{body}}}` is not allowed — a computed expression may only \
292 reference `${{param.NAME}}` or `${{map:NAME|case=value|*=default}}`"
293 )))
294}
295
296fn resolve_map(owner: &str, spec: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
300 let mut parts = spec.split('|');
301 let input_name = parts
302 .next()
303 .map(str::trim)
304 .filter(|s| !s.is_empty())
305 .ok_or_else(|| {
306 CliError::Config(format!(
307 "computed param '{owner}': `${{map:…}}` is missing the switch param name — write \
308 `${{map:NAME|case=value|*=default}}`"
309 ))
310 })?;
311 let input = bound
312 .get(input_name)
313 .ok_or_else(|| CliError::UnknownParamRef {
314 name: input_name.to_string(),
315 token: format!("computed param '{owner}'"),
316 })?;
317 let input_str = value_to_string(input);
318
319 let mut default: Option<String> = None;
320 let mut matched: Option<String> = None;
321 for pair in parts {
322 let (case, value) = pair.split_once('=').ok_or_else(|| {
323 CliError::Config(format!(
324 "computed param '{owner}': map case '{pair}' is not `case=value`"
325 ))
326 })?;
327 let case = case.trim();
328 if case == "*" {
329 default = Some(value.to_string());
330 } else if case == input_str {
331 matched = Some(value.to_string());
332 }
333 }
334 matched.or(default).ok_or_else(|| {
335 CliError::Config(format!(
336 "computed param '{owner}': map has no case for '{input_name}' = '{input_str}' and no \
337 `*` default"
338 ))
339 })
340}
341
342pub fn bind_document(
349 doc: &mut Value,
350 supplied: &SuppliedParams,
351 mode: BindMode,
352) -> CliResult<BoundParams> {
353 let spec = declared(doc)?;
354 let bound = resolve(&spec, supplied, mode)?;
355
356 let stashed = doc.get_mut(PARAMS_KEY).map(std::mem::take);
360 let result = substitute(doc, &bound.values);
361 if let (Some(block), Some(map)) = (stashed, doc.as_object_mut()) {
362 map.insert(PARAMS_KEY.to_string(), block);
363 }
364 result?;
365 Ok(bound)
366}
367
368fn reject_directives(name: &str, raw: &Value) -> CliResult<()> {
372 if let Value::String(s) = raw
373 && s.contains("${")
374 {
375 return Err(CliError::Config(format!(
376 "param '{name}': value contains an interpolation directive (`${{`). Param values are \
377 literal data — put the directive in the config's `params:` default or in the config \
378 body instead"
379 )));
380 }
381 Ok(())
382}
383
384fn substitute(v: &mut Value, bound: &BTreeMap<String, Value>) -> CliResult<()> {
386 if let Value::String(s) = v {
387 let replaced = match whole_token(s, bound)? {
388 Some(typed) => typed,
389 None => Value::String(rewrite_text(s, bound)?),
390 };
391 *v = replaced;
392 return Ok(());
393 }
394 match v {
395 Value::Array(items) => {
396 for item in items.iter_mut() {
397 substitute(item, bound)?;
398 }
399 }
400 Value::Object(map) => {
401 let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
404 for (key, mut val) in entries {
405 substitute(&mut val, bound)?;
406 map.insert(rewrite_text(&key, bound)?, val);
407 }
408 }
409 _ => {}
410 }
411 Ok(())
412}
413
414fn whole_token(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<Option<Value>> {
418 let mut tokens = iter_directives(s);
419 let Some((token, dir)) = tokens.next() else {
420 return Ok(None);
421 };
422 if tokens.next().is_some() || token != s {
423 return Ok(None);
424 }
425 match dir {
426 Directive::Deferred { id, path } if id == PARAM_ID => {
427 Ok(Some(lookup(path, token, bound)?.clone()))
428 }
429 Directive::LoadTime {
430 prefix: "map",
431 body,
432 } => Ok(Some(Value::String(resolve_map(token, body, bound)?))),
433 _ => Ok(None),
434 }
435}
436
437fn rewrite_text(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
440 rewrite(s, |body| match classify_directive(body) {
441 Directive::Deferred { id, path } if id == PARAM_ID => {
442 let token = format!("${{{body}}}");
443 Ok(Some(value_to_string(lookup(path, &token, bound)?)))
444 }
445 Directive::LoadTime {
446 prefix: "map",
447 body: map_body,
448 } => {
449 let token = format!("${{{body}}}");
450 Ok(Some(resolve_map(&token, map_body, bound)?))
451 }
452 _ => Ok(None),
453 })
454}
455
456fn lookup<'a>(path: &str, token: &str, bound: &'a BTreeMap<String, Value>) -> CliResult<&'a Value> {
459 if path.is_empty() {
460 return Err(CliError::Config(format!(
461 "interpolation '{token}' is missing a param name — write `${{param.NAME}}`"
462 )));
463 }
464 if path.contains('.') {
465 return Err(CliError::Config(format!(
466 "interpolation '{token}' is not a valid param reference — params are scalars, so \
467 `${{param.NAME}}` takes a bare name"
468 )));
469 }
470 bound.get(path).ok_or_else(|| CliError::UnknownParamRef {
471 name: path.to_string(),
472 token: token.to_string(),
473 })
474}
475
476pub fn parse_cli_param(arg: &str) -> CliResult<(String, Value)> {
479 let (key, value) = arg.split_once('=').ok_or_else(|| {
480 CliError::Config(format!("invalid --param '{arg}' — expected `name=value`"))
481 })?;
482 let key = key.trim();
483 if key.is_empty() {
484 return Err(CliError::Config(format!(
485 "invalid --param '{arg}' — the name is empty"
486 )));
487 }
488 Ok((key.to_string(), Value::String(value.to_string())))
489}
490
491pub fn collect_cli_params(args: &[String]) -> CliResult<SuppliedParams> {
494 let mut out = SuppliedParams::new();
495 for arg in args {
496 let (k, v) = parse_cli_param(arg)?;
497 if out.insert(k.clone(), v).is_some() {
498 return Err(CliError::Config(format!(
499 "--param '{k}' was given more than once"
500 )));
501 }
502 }
503 Ok(out)
504}
505
506pub fn collect_env_overrides(args: &[String]) -> CliResult<BTreeMap<String, String>> {
510 let mut out = BTreeMap::new();
511 for arg in args {
512 let (name, value) = match arg.split_once('=') {
513 Some((n, v)) => (n.trim().to_string(), v.to_string()),
514 None => {
515 let n = arg.trim().to_string();
516 let v = std::env::var(&n).map_err(|_| {
517 CliError::Config(format!(
518 "--param-env '{n}' has no value and '{n}' is not set in the environment"
519 ))
520 })?;
521 (n, v)
522 }
523 };
524 if name.is_empty() {
525 return Err(CliError::Config(format!(
526 "invalid --param-env '{arg}' — the variable name is empty"
527 )));
528 }
529 if out.insert(name.clone(), value).is_some() {
530 return Err(CliError::Config(format!(
531 "--param-env '{name}' was given more than once"
532 )));
533 }
534 }
535 Ok(out)
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use serde_json::json;
542
543 fn spec_of(yaml: &str) -> ParamsSpec {
544 serde_yaml::from_str(yaml).unwrap()
545 }
546
547 fn supplied(pairs: &[(&str, Value)]) -> SuppliedParams {
548 pairs
549 .iter()
550 .map(|(k, v)| (k.to_string(), v.clone()))
551 .collect()
552 }
553
554 #[test]
555 fn resolves_supplied_default_and_placeholder() {
556 let spec = spec_of(
557 "tenant: { required: true }\n\
558 since: { default: \"1970-01-01\" }\n\
559 page: { type: int, required: true }\n",
560 );
561 let bound = resolve(
562 &spec,
563 &supplied(&[("tenant", json!("acme")), ("page", json!("50"))]),
564 BindMode::Strict,
565 )
566 .unwrap();
567 assert_eq!(bound.values["tenant"], json!("acme"));
568 assert_eq!(bound.values["since"], json!("1970-01-01"));
569 assert_eq!(bound.values["page"], json!(50));
571
572 let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
574 assert_eq!(bound.values["tenant"], json!("<param>"));
575 assert_eq!(bound.values["page"], json!(0));
576 assert_eq!(bound.values["since"], json!("1970-01-01"));
577 }
578
579 #[test]
580 fn computed_param_map_default_and_match() {
581 let spec = spec_of(
582 "region: { default: com }\n\
583 accounts_domain: { computed: \"${map:region|ca=zohocloud|*=zoho}\" }\n",
584 );
585 let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
587 assert_eq!(bound.values["accounts_domain"], json!("zoho"));
588 assert_eq!(bound.values["region"], json!("com"));
589 let bound = resolve(
591 &spec,
592 &supplied(&[("region", json!("ca"))]),
593 BindMode::Strict,
594 )
595 .unwrap();
596 assert_eq!(bound.values["accounts_domain"], json!("zohocloud"));
597 }
598
599 #[test]
600 fn computed_param_rejected_when_supplied() {
601 let spec = spec_of(
602 "region: { default: com }\n\
603 accounts_domain: { computed: \"${map:region|*=zoho}\" }\n",
604 );
605 let err = resolve(
606 &spec,
607 &supplied(&[("accounts_domain", json!("hacked"))]),
608 BindMode::Strict,
609 )
610 .unwrap_err();
611 assert!(
612 matches!(&err, CliError::Config(m) if m.contains("computed") && m.contains("cannot be supplied")),
613 "got {err:?}"
614 );
615 }
616
617 #[test]
618 fn computed_param_chain_resolves_in_dependency_order() {
619 let spec = spec_of(
621 "env: { default: prod }\n\
622 a: { computed: \"${map:env|prod=live|*=test}\" }\n\
623 b: { computed: \"tier-${param.a}\" }\n",
624 );
625 let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
626 assert_eq!(bound.values["a"], json!("live"));
627 assert_eq!(bound.values["b"], json!("tier-live"));
628 }
629
630 #[test]
631 fn computed_param_cycle_is_detected() {
632 let spec = spec_of(
633 "a: { computed: \"${param.b}\" }\n\
634 b: { computed: \"${param.a}\" }\n",
635 );
636 match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
637 CliError::InterpolationCycle { chain } => {
638 assert_eq!(chain, vec!["a".to_string(), "b".to_string()]);
639 }
640 other => panic!("expected InterpolationCycle, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn computed_param_unknown_reference_is_typed() {
646 let spec = spec_of("a: { computed: \"${param.nope}\" }\n");
647 match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
648 CliError::UnknownParamRef { name, .. } => assert_eq!(name, "nope"),
649 other => panic!("expected UnknownParamRef, got {other:?}"),
650 }
651 }
652
653 #[test]
654 fn map_with_no_match_and_no_default_errors() {
655 let spec = spec_of(
656 "region: { default: xx }\n\
657 d: { computed: \"${map:region|ca=zohocloud}\" }\n",
658 );
659 let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
660 assert!(
661 matches!(&err, CliError::Config(m) if m.contains("no case for") && m.contains("no")),
662 "got {err:?}"
663 );
664 }
665
666 #[test]
667 fn computed_unterminated_brace_errors() {
668 let spec = spec_of("a: { computed: \"x-${param.region\" }\n");
670 let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
671 assert!(
672 matches!(&err, CliError::Config(m) if m.contains("unterminated")),
673 "got {err:?}"
674 );
675 }
676
677 #[test]
678 fn computed_disallowed_directive_errors() {
679 let spec = spec_of("a: { computed: \"${env:SECRET}\" }\n");
681 let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
682 assert!(
683 matches!(&err, CliError::Config(m) if m.contains("may only reference")),
684 "got {err:?}"
685 );
686 }
687
688 #[test]
689 fn map_case_not_key_value_errors() {
690 let spec = spec_of(
691 "region: { default: com }\n\
692 d: { computed: \"${map:region|badcase}\" }\n",
693 );
694 let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
695 assert!(
696 matches!(&err, CliError::Config(m) if m.contains("is not `case=value`")),
697 "got {err:?}"
698 );
699 }
700
701 #[test]
702 fn standalone_map_missing_switch_name_errors() {
703 let mut doc = json!({
705 "pipeline": { "source": { "config": { "h": "${map:|a=b}" } } }
706 });
707 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
708 assert!(
709 matches!(&err, CliError::Config(m) if m.contains("missing the switch param name")),
710 "got {err:?}"
711 );
712 }
713
714 #[test]
715 fn standalone_map_unknown_input_errors() {
716 let mut doc = json!({
717 "pipeline": { "source": { "config": { "h": "${map:nope|*=x}" } } }
718 });
719 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
720 assert!(
721 matches!(&err, CliError::UnknownParamRef { name, .. } if name == "nope"),
722 "got {err:?}"
723 );
724 }
725
726 #[test]
727 fn whole_token_map_resolves_with_declared_type() {
728 let mut doc = json!({
730 "params": { "region": { "default": "com" } },
731 "pipeline": { "source": { "config": {
732 "domain": "${map:region|ca=zohocloud|*=zoho}"
733 } } }
734 });
735 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
736 assert_eq!(doc["pipeline"]["source"]["config"]["domain"], json!("zoho"));
737 }
738
739 #[test]
740 fn standalone_map_token_resolves_in_document() {
741 let mut doc = json!({
743 "params": { "region": { "default": "ca" } },
744 "pipeline": { "source": { "config": {
745 "host": "accounts.${map:region|ca=zohocloud|*=zoho}.${param.region}"
746 } } }
747 });
748 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
749 assert_eq!(
750 doc["pipeline"]["source"]["config"]["host"],
751 json!("accounts.zohocloud.ca")
752 );
753 }
754
755 #[test]
756 fn bind_document_substitutes_computed_param() {
757 let mut doc = json!({
758 "params": {
759 "region": { "default": "com" },
760 "accounts_domain": { "computed": "${map:region|ca=zohocloud|*=zoho}" }
761 },
762 "pipeline": { "source": { "config": {
763 "base_url": "https://accounts.${param.accounts_domain}.${param.region}"
764 } } }
765 });
766 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
767 assert_eq!(
768 doc["pipeline"]["source"]["config"]["base_url"],
769 json!("https://accounts.zoho.com")
770 );
771 assert_eq!(
773 doc["params"]["accounts_domain"]["computed"],
774 json!("${map:region|ca=zohocloud|*=zoho}")
775 );
776 }
777
778 #[test]
779 fn missing_required_param_is_a_typed_error() {
780 let spec = spec_of("tenant: { required: true, description: Tenant to sync }\n");
781 match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
782 CliError::MissingParam { name, description } => {
783 assert_eq!(name, "tenant");
784 assert_eq!(description.as_deref(), Some("Tenant to sync"));
785 }
786 other => panic!("expected MissingParam, got {other:?}"),
787 }
788 }
789
790 #[test]
791 fn unknown_supplied_param_is_rejected() {
792 let spec = spec_of("tenant: { required: true }\n");
793 match resolve(
794 &spec,
795 &supplied(&[("tenant", json!("a")), ("tenatn", json!("b"))]),
796 BindMode::Strict,
797 )
798 .unwrap_err()
799 {
800 CliError::UnknownParam { name, known } => {
801 assert_eq!(name, "tenatn");
802 assert_eq!(known, vec!["tenant".to_string()]);
803 }
804 other => panic!("expected UnknownParam, got {other:?}"),
805 }
806 }
807
808 #[test]
809 fn supplied_value_may_not_carry_a_directive() {
810 let spec = spec_of("t: { required: true }\n");
811 let err = resolve(
812 &spec,
813 &supplied(&[("t", json!("${vault:secret/data/db#password}"))]),
814 BindMode::Strict,
815 )
816 .unwrap_err()
817 .to_string();
818 assert!(err.contains("literal data"), "{err}");
819 }
820
821 #[test]
822 fn binds_document_typed_and_textual() {
823 let mut doc = json!({
824 "version": 1,
825 "params": {
826 "tenant": { "required": true },
827 "page": { "type": "int", "default": 500 },
828 "live": { "type": "bool", "default": true }
829 },
830 "pipeline": {
831 "source": {
832 "type": "rest",
833 "config": {
834 "url": "https://api.example.com/${param.tenant}/events",
835 "page_size": "${param.page}",
836 "streaming": "${param.live}"
837 }
838 }
839 }
840 });
841 let bound = bind_document(
842 &mut doc,
843 &supplied(&[("tenant", json!("acme"))]),
844 BindMode::Strict,
845 )
846 .unwrap();
847 let cfg = &doc["pipeline"]["source"]["config"];
848 assert_eq!(cfg["url"], "https://api.example.com/acme/events");
849 assert_eq!(cfg["page_size"], json!(500));
851 assert_eq!(cfg["streaming"], json!(true));
852 assert_eq!(doc["params"]["page"]["default"], json!(500));
854 assert_eq!(bound.values["tenant"], json!("acme"));
855 }
856
857 #[test]
858 fn defaults_inside_the_params_block_are_not_substituted() {
859 let mut doc = json!({
862 "params": { "a": { "default": "${param.a}" } },
863 "pipeline": { "x": "ok" }
864 });
865 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
866 assert_eq!(doc["params"]["a"]["default"], "${param.a}");
867 }
868
869 #[test]
870 fn undeclared_reference_is_rejected() {
871 let mut doc = json!({
872 "params": { "a": { "default": "1" } },
873 "pipeline": { "url": "${param.b}" }
874 });
875 match bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
876 CliError::UnknownParamRef { name, token } => {
877 assert_eq!(name, "b");
878 assert_eq!(token, "${param.b}");
879 }
880 other => panic!("expected UnknownParamRef, got {other:?}"),
881 }
882 }
883
884 #[test]
885 fn reference_without_a_params_block_is_rejected() {
886 let mut doc = json!({ "pipeline": { "url": "${param.x}" } });
889 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
890 assert!(matches!(err, CliError::UnknownParamRef { .. }), "{err:?}");
891 }
892
893 #[test]
894 fn supplying_a_param_with_no_block_is_rejected() {
895 let mut doc = json!({ "pipeline": {} });
896 match bind_document(&mut doc, &supplied(&[("x", json!("1"))]), BindMode::Strict)
897 .unwrap_err()
898 {
899 CliError::UnknownParam { known, .. } => assert!(known.is_empty()),
900 other => panic!("expected UnknownParam, got {other:?}"),
901 }
902 }
903
904 #[test]
905 fn malformed_references_are_rejected() {
906 for bad in ["${param}", "${param.a.b}"] {
907 let mut doc = json!({
908 "params": { "a": { "default": "1" } },
909 "pipeline": { "url": bad }
910 });
911 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict)
912 .unwrap_err()
913 .to_string();
914 assert!(err.contains("param"), "{bad}: {err}");
915 }
916 }
917
918 #[test]
919 fn escaped_token_stays_literal() {
920 let mut doc = json!({
921 "params": { "a": { "default": "v" } },
922 "pipeline": { "note": "$${param.a}" }
923 });
924 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
925 assert_eq!(doc["pipeline"]["note"], "${param.a}");
926 }
927
928 #[test]
929 fn other_namespaces_survive_binding() {
930 let mut doc = json!({
931 "params": { "a": { "default": "v" } },
932 "pipeline": { "url": "${param.a}/${now.date}/${users.id}" }
933 });
934 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
935 assert_eq!(doc["pipeline"]["url"], "v/${now.date}/${users.id}");
936 }
937
938 #[test]
939 fn substitutes_into_keys_and_arrays() {
940 let mut doc = json!({
941 "params": { "h": { "default": "X-Tenant" }, "n": { "type": "int", "default": 2 } },
942 "pipeline": {
943 "headers": { "${param.h}": "v" },
944 "list": ["${param.n}", "n=${param.n}"]
945 }
946 });
947 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
948 assert_eq!(doc["pipeline"]["headers"]["X-Tenant"], "v");
949 assert_eq!(doc["pipeline"]["list"][0], json!(2));
950 assert_eq!(doc["pipeline"]["list"][1], json!("n=2"));
951 }
952
953 #[test]
954 fn secret_params_are_tracked_and_redacted() {
955 let spec = spec_of("token: { required: true, secret: true }\nuser: { default: bob }\n");
956 let bound = resolve(
957 &spec,
958 &supplied(&[("token", json!("s3cret-value-long-enough"))]),
959 BindMode::Strict,
960 )
961 .unwrap();
962 assert!(bound.has_secrets());
963 let red = bound.redacted();
964 assert_eq!(red["token"], json!("***"));
965 assert_eq!(red["user"], json!("bob"));
966 assert_eq!(
968 crate::secrets::registry::redact("token=s3cret-value-long-enough"),
969 "token=***"
970 );
971 }
972
973 #[test]
974 fn invalid_params_block_is_a_config_error() {
975 let doc = json!({ "params": { "a": { "type": "date" } } });
976 let err = declared(&doc).unwrap_err().to_string();
977 assert!(err.contains("`params:` block"), "{err}");
978 assert!(declared(&json!({ "params": null })).unwrap().is_empty());
980 assert!(declared(&json!({})).unwrap().is_empty());
981 }
982
983 #[test]
984 fn cli_param_parsing() {
985 let (k, v) = parse_cli_param("tenant=acme").unwrap();
986 assert_eq!(k, "tenant");
987 assert_eq!(v, json!("acme"));
988 let (_, v) = parse_cli_param("q=a=b").unwrap();
990 assert_eq!(v, json!("a=b"));
991 let (_, v) = parse_cli_param("q=").unwrap();
993 assert_eq!(v, json!(""));
994 assert!(parse_cli_param("noequals").is_err());
995 assert!(parse_cli_param("=v").is_err());
996
997 let map = collect_cli_params(&["a=1".into(), "b=2".into()]).unwrap();
998 assert_eq!(map.len(), 2);
999 let err = collect_cli_params(&["a=1".into(), "a=2".into()])
1000 .unwrap_err()
1001 .to_string();
1002 assert!(err.contains("more than once"), "{err}");
1003 }
1004
1005 #[test]
1006 fn env_override_parsing() {
1007 let map = collect_env_overrides(&["A=1".into()]).unwrap();
1008 assert_eq!(map["A"], "1");
1009 unsafe { std::env::set_var("FAUCET_PARAM_ENV_TEST", "from-env") };
1011 let map = collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).unwrap();
1012 assert_eq!(map["FAUCET_PARAM_ENV_TEST"], "from-env");
1013 unsafe { std::env::remove_var("FAUCET_PARAM_ENV_TEST") };
1014 assert!(collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).is_err());
1015 assert!(collect_env_overrides(&["=1".into()]).is_err());
1016 assert!(collect_env_overrides(&["A=1".into(), "A=2".into()]).is_err());
1017 }
1018
1019 #[test]
1020 fn placeholder_mode_leaves_a_bindable_document() {
1021 let mut doc = json!({
1024 "params": { "t": { "required": true }, "n": { "type": "int", "required": true } },
1025 "pipeline": { "source": { "config": { "url": "https://x/${param.t}", "n": "${param.n}" } } }
1026 });
1027 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
1028 let cfg = &doc["pipeline"]["source"]["config"];
1029 assert_eq!(cfg["url"], "https://x/<param>");
1030 assert_eq!(cfg["n"], json!(0));
1031 }
1032
1033 #[test]
1034 fn bound_params_default_is_empty() {
1035 let b = BoundParams::default();
1036 assert!(!b.has_secrets());
1037 assert!(b.redacted().is_empty());
1038 }
1039
1040 #[test]
1041 fn binding_a_non_object_document_is_a_no_op() {
1042 let mut doc = json!(["${param.a}"]);
1044 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
1045 assert!(matches!(err, CliError::UnknownParamRef { .. }));
1046 let mut doc = json!(7);
1047 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
1048 assert_eq!(doc, json!(7));
1049 }
1050}