use serde::Deserialize as _;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use noyalib::compat::serde_yaml;
use crate::commands::test::document::TestDocError;
use crate::commands::test::runner::find_camel_toml_root;
const BODY_SCALAR_SENTINEL: &str = "unsupported body scalar: ";
pub(crate) const JOB_SAFE_CONSUMER_SCHEMES: [&str; 4] = ["direct", "seda", "log", "mock"];
const JOB_SEND_SCHEMES: [&str; 2] = ["direct", "seda"];
#[derive(Debug)]
pub(crate) struct JobDocument {
pub(crate) execute: ExecuteSection,
pub(crate) args: Option<JobArgumentDeclarations>,
pub(crate) route_files: Option<Vec<String>>,
pub(crate) route_files_from_root: Option<Vec<String>>,
pub(crate) routes: Option<serde_yaml::Value>,
}
impl JobDocument {
pub(crate) fn legacy_arg_headers(&self) -> bool {
self.args.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum JobMode {
OneShot,
Batch,
}
impl JobMode {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::OneShot => "one-shot",
Self::Batch => "batch",
}
}
}
#[derive(Debug)]
pub(crate) struct ExecuteSection {
pub(crate) mode: JobMode,
pub(crate) send: JobSendAction,
pub(crate) capture_reply: bool,
pub(crate) timeout: std::time::Duration,
}
#[derive(Debug)]
pub(crate) struct JobSendAction {
pub(crate) to: String,
pub(crate) body: Option<JobBody>,
pub(crate) headers: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug)]
pub(crate) enum JobBody {
Text(String),
Json(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum JobArgType {
String,
Int,
Bool,
Enum(Vec<String>),
}
impl JobArgType {
pub(crate) fn render(&self) -> String {
match self {
Self::String => "string".to_string(),
Self::Int => "int".to_string(),
Self::Bool => "bool".to_string(),
Self::Enum(members) => format!("enum[{}]", members.join(",")),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct JobArgumentDeclaration {
pub(crate) required: bool,
pub(crate) default: Option<String>,
pub(crate) description: Option<String>,
pub(crate) arg_type: JobArgType,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct JobArgumentDeclarations {
pub(crate) entries: BTreeMap<String, JobArgumentDeclaration>,
}
#[derive(Debug)]
pub(crate) enum JobDocError {
NotJobSuffix { path: String },
Yaml(String),
UnknownField(String),
MissingExecute,
ExclusiveWithScenario,
MixedVocabulary { sections: Vec<&'static str> },
MissingMode,
UnsupportedMode(String),
MissingTimeout,
InvalidTimeout(String),
UnsupportedSendScheme { to: String },
UnsupportedBodyScalar(String),
InvalidArgumentName { name: String },
UnknownArgumentField { argument: String, field: String },
InvalidArgumentType { argument: String, raw: String },
ArgumentCoercion {
name: String,
expected: JobArgType,
raw: String,
},
InvalidArgumentDeclaration { argument: String, detail: String },
UnknownArgumentName { name: String },
MissingRequiredArgument { name: String },
UnresolvedArgument { name: String },
RouteSource(TestDocError),
}
impl std::fmt::Display for JobDocError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotJobSuffix { path } => write!(
f,
"job document {path} must use the reserved .job.yaml/.job.yml suffix (rename it — .test.yaml names a camel test document)"
),
Self::Yaml(raw) => write!(f, "invalid job document: {raw}"),
Self::UnknownField(raw) => write!(f, "unknown field in job document: {raw}"),
Self::MissingExecute => {
write!(f, "job document requires an execute: section")
}
Self::ExclusiveWithScenario => {
write!(f, "execute: and scenario: are mutually exclusive sections")
}
Self::MixedVocabulary { sections } => write!(
f,
"execute: is mutually exclusive with the test sections {}",
sections
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(", ")
),
Self::MissingMode => write!(f, "execute.mode is required"),
Self::UnsupportedMode(mode) => write!(
f,
"unsupported execute.mode `{mode}`: expected `one-shot` or `batch`"
),
Self::MissingTimeout => write!(f, "execute.timeout is required"),
Self::InvalidTimeout(raw) => write!(
f,
"invalid execute.timeout `{raw}`: expected a positive duration (e.g. `30s`)"
),
Self::UnsupportedSendScheme { to } => {
write!(f, "send target `{to}` must start with `direct:` or `seda:`")
}
Self::UnsupportedBodyScalar(raw) => write!(
f,
"unsupported body scalar `{raw}`: only string, object, and array bodies are supported"
),
Self::InvalidArgumentName { name } => write!(
f,
"invalid argument name `{name}` in `args:`: names must match [A-Za-z_][A-Za-z0-9_]*"
),
Self::UnknownArgumentField { argument, field } => write!(
f,
"unknown field `{field}` in the declaration of argument `{argument}`: expected `required`, `default`, `description`, or `type`"
),
Self::InvalidArgumentType { argument, raw } => write!(
f,
"invalid type `{raw}` for argument `{argument}`: expected `string`, `int`, `bool`, or `enum[...]`"
),
Self::ArgumentCoercion {
name,
expected,
raw,
} => write!(
f,
"invalid value `{raw}` for argument `{name}`: expected type `{}`",
expected.render()
),
Self::InvalidArgumentDeclaration { argument, detail } => {
write!(f, "invalid declaration for argument `{argument}`: {detail}")
}
Self::UnknownArgumentName { name } => write!(
f,
"unknown argument `{name}` in `--arg`: not declared in the document's `args:` block"
),
Self::MissingRequiredArgument { name } => write!(
f,
"missing required argument `{name}`: pass --arg {name}=<value>"
),
Self::UnresolvedArgument { name } => write!(
f,
"unresolved argument `{name}` in job document: no declared value \
and no matching environment variable"
),
Self::RouteSource(err) => write!(f, "{err}"),
}
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct ExecuteSectionDoc {
mode: Option<String>,
#[serde(default)]
send: Option<JobSendActionDoc>,
#[serde(rename = "capture-reply")]
capture_reply: Option<bool>,
timeout: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct JobSendActionDoc {
to: String,
#[serde(default, deserialize_with = "deserialize_option_job_body")]
body: Option<JobBody>,
headers: Option<HashMap<String, serde_json::Value>>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct JobDocumentDoc {
execute: ExecuteSectionDoc,
#[serde(default)]
#[expect(dead_code, reason = "admit-only under deny_unknown_fields")]
description: Option<String>,
#[serde(default)]
args: Option<BTreeMap<String, serde_yaml::Value>>,
route_files: Option<Vec<String>>,
route_files_from_root: Option<Vec<String>>,
routes: Option<serde_yaml::Value>,
}
fn job_body_from_value(value: serde_json::Value) -> Result<Option<JobBody>, String> {
match &value {
serde_json::Value::String(s) => Ok(Some(JobBody::Text(s.clone()))),
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
Ok(Some(JobBody::Json(value)))
}
scalar => Err(format!("{BODY_SCALAR_SENTINEL}{scalar}")),
}
}
fn deserialize_option_job_body<'de, D>(deserializer: D) -> Result<Option<JobBody>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
job_body_from_value(value).map_err(serde::de::Error::custom)
}
const TEST_VOCABULARY_KEYS: [&str; 8] = [
"inputs",
"expects",
"intercepts",
"beans",
"repositories",
"sequence",
"settle",
"env",
];
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn parse_job_document(path: &Path, text: &str) -> Result<JobDocument, JobDocError> {
parse_job_document_impl(path, text, None)
}
pub(crate) fn parse_job_document_with_args(
path: &Path,
text: &str,
cli_args: &[(String, String)],
) -> Result<JobDocument, JobDocError> {
parse_job_document_impl(path, text, Some(cli_args))
}
fn parse_job_document_impl(
path: &Path,
text: &str,
cli_args: Option<&[(String, String)]>,
) -> Result<JobDocument, JobDocError> {
if !camel_dsl::discovery::is_job_document(path) {
return Err(JobDocError::NotJobSuffix {
path: path.display().to_string(),
});
}
let value = serde_yaml::from_str::<serde_yaml::Value>(text)
.map_err(|e| JobDocError::Yaml(e.to_string()))?;
let has = |key: &str| value.get(key).is_some();
if !has("execute") {
return Err(JobDocError::MissingExecute);
}
if has("scenario") {
return Err(JobDocError::ExclusiveWithScenario);
}
let mixed: Vec<&'static str> = TEST_VOCABULARY_KEYS
.iter()
.copied()
.filter(|key| has(key))
.collect();
if !mixed.is_empty() {
return Err(JobDocError::MixedVocabulary { sections: mixed });
}
let mut raw =
serde_yaml::from_str::<JobDocumentDoc>(text).map_err(|e| classify(&e.to_string()))?;
let mut present: Vec<&'static str> = Vec::new();
if raw.route_files.is_some() {
present.push("routeFiles");
}
if raw.route_files_from_root.is_some() {
present.push("routeFilesFromRoot");
}
if raw.routes.is_some() {
present.push("routes");
}
if present.len() != 1 {
return Err(JobDocError::RouteSource(
TestDocError::RouteSourceConflict { present },
));
}
let args = normalize_job_args(raw.args.take())?;
let resolved = match cli_args {
Some(pairs) => resolve_job_args(args.as_ref(), pairs)?,
None => None,
};
if let Some(resolved) = &resolved {
interpolate_declared_fields(&mut raw, resolved)?;
}
let execute = raw.execute;
let mode = match execute.mode.ok_or(JobDocError::MissingMode)?.as_str() {
"one-shot" => JobMode::OneShot,
"batch" => JobMode::Batch,
other => return Err(JobDocError::UnsupportedMode(other.to_string())),
};
let timeout_raw = execute.timeout.ok_or(JobDocError::MissingTimeout)?;
let timeout = humantime::parse_duration(&timeout_raw)
.ok()
.filter(|d| *d > std::time::Duration::ZERO)
.ok_or_else(|| JobDocError::InvalidTimeout(timeout_raw.clone()))?;
let send = execute.send.ok_or(JobDocError::Yaml(
"execute.send is required: exactly one send action".to_string(),
))?;
if !JOB_SEND_SCHEMES
.iter()
.any(|scheme| send.to.starts_with(&format!("{scheme}:")))
{
return Err(JobDocError::UnsupportedSendScheme { to: send.to });
}
Ok(JobDocument {
execute: ExecuteSection {
mode,
send: JobSendAction {
to: send.to,
body: send.body,
headers: send.headers,
},
capture_reply: execute.capture_reply.unwrap_or(false),
timeout,
},
route_files: raw.route_files,
route_files_from_root: raw.route_files_from_root,
routes: raw.routes,
args,
})
}
#[derive(Debug)]
pub(crate) struct JobHelpInfo {
pub(crate) mode: String,
pub(crate) send_to: String,
pub(crate) args: Option<JobArgumentDeclarations>,
}
pub(crate) fn parse_job_document_for_help(
path: &Path,
text: &str,
) -> Result<JobHelpInfo, JobDocError> {
if !camel_dsl::discovery::is_job_document(path) {
return Err(JobDocError::NotJobSuffix {
path: path.display().to_string(),
});
}
let value = serde_yaml::from_str::<serde_yaml::Value>(text)
.map_err(|e| JobDocError::Yaml(e.to_string()))?;
let has = |key: &str| value.get(key).is_some();
if !has("execute") {
return Err(JobDocError::MissingExecute);
}
if has("scenario") {
return Err(JobDocError::ExclusiveWithScenario);
}
let mixed: Vec<&'static str> = TEST_VOCABULARY_KEYS
.iter()
.copied()
.filter(|key| has(key))
.collect();
if !mixed.is_empty() {
return Err(JobDocError::MixedVocabulary { sections: mixed });
}
let mut raw =
serde_yaml::from_str::<JobDocumentDoc>(text).map_err(|e| classify(&e.to_string()))?;
let mut present: Vec<&'static str> = Vec::new();
if raw.route_files.is_some() {
present.push("routeFiles");
}
if raw.route_files_from_root.is_some() {
present.push("routeFilesFromRoot");
}
if raw.routes.is_some() {
present.push("routes");
}
if present.len() != 1 {
return Err(JobDocError::RouteSource(
TestDocError::RouteSourceConflict { present },
));
}
let args = normalize_job_args(raw.args.take())?;
let execute = raw.execute;
let mode_raw = execute.mode.ok_or(JobDocError::MissingMode)?;
let mode = match mode_raw.as_str() {
"one-shot" | "batch" => mode_raw,
other => return Err(JobDocError::UnsupportedMode(other.to_string())),
};
execute.timeout.ok_or(JobDocError::MissingTimeout)?;
let send_to = execute
.send
.ok_or(JobDocError::Yaml(
"execute.send is required: exactly one send action".to_string(),
))?
.to;
Ok(JobHelpInfo {
mode,
send_to,
args,
})
}
fn is_argument_identifier(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
fn normalize_job_args(
raw: Option<BTreeMap<String, serde_yaml::Value>>,
) -> Result<Option<JobArgumentDeclarations>, JobDocError> {
let Some(raw) = raw else {
return Ok(None);
};
let mut entries = BTreeMap::new();
for (name, value) in raw {
if !is_argument_identifier(&name) {
return Err(JobDocError::InvalidArgumentName { name });
}
let declaration = job_argument_declaration(&name, &value)?;
if declaration.arg_type != JobArgType::String
&& let Some(default) = &declaration.default
&& coerce_argument(default, &declaration.arg_type).is_none()
{
return Err(JobDocError::ArgumentCoercion {
name,
expected: declaration.arg_type.clone(),
raw: default.clone(),
});
}
entries.insert(name, declaration);
}
Ok(Some(JobArgumentDeclarations { entries }))
}
pub(crate) fn validate_job_declarations_for_compile(text: &str) -> Result<(), JobDocError> {
let value = serde_yaml::from_str::<serde_yaml::Value>(text)
.map_err(|e| JobDocError::Yaml(e.to_string()))?;
let args = match value.get("args") {
None | Some(serde_yaml::Value::Null) => return Ok(()),
Some(args) => args,
};
let mapping = args.as_mapping().ok_or_else(|| {
JobDocError::Yaml(
"top-level `args:` must be a mapping of argument declarations".to_string(),
)
})?;
let raw = mapping
.iter()
.map(|(key, value)| (key.as_str().to_string(), value.clone()))
.collect::<BTreeMap<String, serde_yaml::Value>>();
normalize_job_args(Some(raw)).map(|_| ())
}
fn job_argument_declaration(
name: &str,
value: &serde_yaml::Value,
) -> Result<JobArgumentDeclaration, JobDocError> {
let invalid = |detail: String| JobDocError::InvalidArgumentDeclaration {
argument: name.to_string(),
detail,
};
let mapping = value.as_mapping().ok_or_else(|| {
invalid(
"expected a mapping of `required`, `default`, `description`, and `type` fields"
.to_string(),
)
})?;
let mut declaration = JobArgumentDeclaration {
required: false,
default: None,
description: None,
arg_type: JobArgType::String,
};
for (key, val) in mapping {
match key.as_str() {
"required" => {
declaration.required = val
.as_bool()
.ok_or_else(|| invalid("`required` must be a boolean".to_string()))?;
}
"default" => {
let raw = val
.as_str()
.ok_or_else(|| invalid("`default` must be a string".to_string()))?;
declaration.default = Some(raw.to_string());
}
"description" => {
let raw = val
.as_str()
.ok_or_else(|| invalid("`description` must be a string".to_string()))?;
declaration.description = Some(raw.to_string());
}
"type" => {
let raw = val
.as_str()
.ok_or_else(|| invalid("`type` must be a string".to_string()))?;
declaration.arg_type =
parse_arg_type(raw).map_err(|raw| JobDocError::InvalidArgumentType {
argument: name.to_string(),
raw,
})?;
}
other => {
return Err(JobDocError::UnknownArgumentField {
argument: name.to_string(),
field: other.to_string(),
});
}
}
}
Ok(declaration)
}
fn parse_arg_type(raw: &str) -> Result<JobArgType, String> {
match raw {
"string" => return Ok(JobArgType::String),
"int" => return Ok(JobArgType::Int),
"bool" => return Ok(JobArgType::Bool),
_ => {}
}
let interior = raw
.strip_prefix("enum[")
.and_then(|rest| rest.strip_suffix(']'))
.ok_or_else(|| raw.to_string())?;
let mut members: Vec<String> = Vec::new();
for member in interior.split(',') {
let member = member.trim();
if member.is_empty()
|| ["[", "]", "\r", "\n"]
.iter()
.any(|forbidden| member.contains(forbidden))
|| members.iter().any(|seen| seen == member)
{
return Err(raw.to_string());
}
members.push(member.to_string());
}
Ok(JobArgType::Enum(members))
}
fn coerce_argument(value: &str, arg_type: &JobArgType) -> Option<String> {
match arg_type {
JobArgType::String => Some(value.to_string()),
JobArgType::Int => value.parse::<i64>().ok().map(|int| int.to_string()),
JobArgType::Bool => match value.to_ascii_lowercase().as_str() {
"true" => Some("true".to_string()),
"false" => Some("false".to_string()),
_ => None,
},
JobArgType::Enum(members) => members.iter().find(|m| *m == value).cloned(),
}
}
pub(crate) fn resolve_job_args(
declarations: Option<&JobArgumentDeclarations>,
pairs: &[(String, String)],
) -> Result<Option<BTreeMap<String, String>>, JobDocError> {
let Some(declarations) = declarations else {
return Ok(None);
};
let mut resolved = BTreeMap::new();
for (name, value) in pairs {
if !declarations.entries.contains_key(name) {
return Err(JobDocError::UnknownArgumentName { name: name.clone() });
}
resolved.insert(name.clone(), value.clone());
}
for (name, declaration) in &declarations.entries {
if declaration.required && declaration.default.is_none() && !resolved.contains_key(name) {
return Err(JobDocError::MissingRequiredArgument { name: name.clone() });
}
if let Some(default) = &declaration.default {
resolved
.entry(name.clone())
.or_insert_with(|| default.clone());
}
}
for (name, declaration) in &declarations.entries {
if declaration.arg_type == JobArgType::String {
continue;
}
if let Some(raw) = resolved.get(name).cloned() {
match coerce_argument(&raw, &declaration.arg_type) {
Some(canonical) => {
resolved.insert(name.clone(), canonical);
}
None => {
return Err(JobDocError::ArgumentCoercion {
name: name.clone(),
expected: declaration.arg_type.clone(),
raw,
});
}
}
}
}
Ok(Some(resolved))
}
fn interpolate_job_string(
src: &str,
resolved: &BTreeMap<String, String>,
) -> Result<String, JobDocError> {
let env_lookup = |name: &str| std::env::var(name).ok();
let arg_lookup = |name: &str| resolved.get(name).cloned();
camel_dsl::interpolate_with_args(src, &env_lookup, &arg_lookup)
.map_err(|name| JobDocError::UnresolvedArgument { name })
}
fn interpolate_json_strings(
value: &mut serde_json::Value,
resolved: &BTreeMap<String, String>,
) -> Result<(), JobDocError> {
match value {
serde_json::Value::String(text) => {
*text = interpolate_job_string(text, resolved)?;
}
serde_json::Value::Array(items) => {
for item in items {
interpolate_json_strings(item, resolved)?;
}
}
serde_json::Value::Object(map) => {
for item in map.values_mut() {
interpolate_json_strings(item, resolved)?;
}
}
_ => {}
}
Ok(())
}
fn interpolate_declared_fields(
raw: &mut JobDocumentDoc,
resolved: &BTreeMap<String, String>,
) -> Result<(), JobDocError> {
if let Some(send) = raw.execute.send.as_mut() {
send.to = interpolate_job_string(&send.to, resolved)?;
if let Some(JobBody::Text(text)) = send.body.as_mut() {
*text = interpolate_job_string(text, resolved)?;
}
if let Some(JobBody::Json(value)) = send.body.as_mut() {
interpolate_json_strings(value, resolved)?;
}
if let Some(headers) = send.headers.as_mut() {
for header in headers.values_mut() {
interpolate_json_strings(header, resolved)?;
}
}
}
if let Some(timeout) = raw.execute.timeout.as_mut() {
*timeout = interpolate_job_string(timeout, resolved)?;
}
Ok(())
}
fn classify(raw: &str) -> JobDocError {
if let Some((_, after)) = raw.split_once(BODY_SCALAR_SENTINEL) {
let scalar = after.split_whitespace().next().unwrap_or_default();
return JobDocError::UnsupportedBodyScalar(scalar.to_string());
}
if raw.contains("unknown field") {
return JobDocError::UnknownField(raw.to_string());
}
JobDocError::Yaml(raw.to_string())
}
pub(crate) enum JobRouteSource {
Patterns(Vec<String>),
Inline(String),
}
pub(crate) fn resolve_route_source(
doc: &JobDocument,
doc_dir: &Path,
) -> Result<JobRouteSource, JobDocError> {
if let Some(files) = &doc.route_files_from_root {
let root = find_camel_toml_root(doc_dir).ok_or_else(|| {
JobDocError::RouteSource(TestDocError::NoProjectRoot {
doc_dir: doc_dir.display().to_string(),
})
})?;
Ok(JobRouteSource::Patterns(
files
.iter()
.map(|p| root.join(p).display().to_string())
.collect(),
))
} else if let Some(files) = &doc.route_files {
Ok(JobRouteSource::Patterns(
files
.iter()
.map(|p| doc_dir.join(p).display().to_string())
.collect(),
))
} else if let Some(value) = &doc.routes {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert("routes", value.clone());
let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
.map_err(|e| JobDocError::Yaml(format!("failed to serialize inline routes: {e}")))?;
Ok(JobRouteSource::Inline(text))
} else {
Err(JobDocError::RouteSource(
TestDocError::RouteSourceConflict {
present: Vec::new(),
},
))
}
}
pub(crate) fn scheme_of_uri(uri: &str) -> Option<&str> {
uri.split_once(':').map(|(scheme, _)| scheme)
}
pub(crate) fn uri_base(uri: &str) -> &str {
uri.split('?').next().unwrap_or(uri)
}
pub(crate) fn seda_send_uri(to: &str) -> String {
const PARAM: &str = "waitForTaskToComplete";
match to.split_once('?') {
None => format!("{to}?{PARAM}=Always"),
Some((base, query)) => {
let kept: Vec<&str> = query
.split('&')
.filter(|pair| !pair.starts_with(&format!("{PARAM}=")))
.collect();
let mut uri = String::from(base);
uri.push('?');
if !kept.is_empty() {
uri.push_str(&kept.join("&"));
uri.push('&');
}
uri.push_str(&format!("{PARAM}=Always"));
uri
}
}
}
pub(crate) fn target_route_ids(
defs: &[camel_core::RouteDefinition],
target_base: &str,
) -> Vec<String> {
defs.iter()
.filter(|def| uri_base(def.from_uri()) == target_base)
.map(|def| def.route_id().to_string())
.collect()
}
pub(crate) fn validate_consumer_uri(from_uri: &str) -> Result<(), String> {
let scheme = scheme_of_uri(from_uri).unwrap_or_default();
if JOB_SAFE_CONSUMER_SCHEMES.contains(&scheme) {
Ok(())
} else {
Err(format!(
"route consumes from `{from_uri}`; one-shot job documents allow only {} \
consumers (producers/sinks as to: URIs are unrestricted); scheme `{scheme}` \
is rejected",
JOB_SAFE_CONSUMER_SCHEMES
.iter()
.map(|s| format!("`{s}:`"))
.collect::<Vec<_>>()
.join(", ")
))
}
}