use crate::errors::RuntimeError;
pub const ARTIFACT_CONFIG_ENV: &str = "MURMUR_ARTIFACT_CONFIG";
pub const MAX_ARTIFACT_CONFIG_BYTES: usize = 65_536;
pub fn lower_artifact_config(
artifact: &str,
config: &serde_yaml::Value,
) -> Result<String, RuntimeError> {
let refuse = |message: String| RuntimeError::InvalidArtifactConfig {
artifact: artifact.to_string(),
message,
};
let mapping = config.as_mapping().ok_or_else(|| {
refuse(format!(
"'config:' must be a mapping of keys to values, but this entry declares {}",
describe_yaml_shape(config)
))
})?;
for key in mapping.keys() {
if !key.is_string() {
return Err(refuse(format!(
"every key in 'config:' must be a string, but this entry declares {} key {}",
describe_yaml_shape(key),
render_yaml_scalar(key)
)));
}
}
let json = serde_json::to_string(mapping)
.map_err(|err| refuse(format!("'config:' does not serialize to JSON: {err}")))?;
if json.len() > MAX_ARTIFACT_CONFIG_BYTES {
return Err(refuse(format!(
"'config:' serializes to {} bytes of JSON, over the {MAX_ARTIFACT_CONFIG_BYTES}-byte \
limit for {ARTIFACT_CONFIG_ENV}",
json.len()
)));
}
Ok(json)
}
pub fn configured_artifact_names<'a, I>(artifacts: I) -> Result<Vec<String>, RuntimeError>
where
I: IntoIterator<Item = (&'a str, Option<&'a serde_yaml::Value>)>,
{
let mut names = Vec::new();
for (name, config) in artifacts {
let Some(config) = config else {
continue;
};
lower_artifact_config(name, config)?;
names.push(name.to_string());
}
Ok(names)
}
fn describe_yaml_shape(value: &serde_yaml::Value) -> &'static str {
match value {
serde_yaml::Value::Null => "an empty block",
serde_yaml::Value::Bool(_) => "a boolean",
serde_yaml::Value::Number(_) => "a number",
serde_yaml::Value::String(_) => "a string",
serde_yaml::Value::Sequence(_) => "a sequence",
serde_yaml::Value::Mapping(_) => "a mapping",
serde_yaml::Value::Tagged(_) => "a tagged value",
}
}
fn render_yaml_scalar(value: &serde_yaml::Value) -> String {
match value {
serde_yaml::Value::Bool(value) => value.to_string(),
serde_yaml::Value::Number(value) => value.to_string(),
serde_yaml::Value::String(value) => format!("'{value}'"),
other => serde_json::to_string(other).unwrap_or_else(|_| "<unprintable>".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn yaml(source: &str) -> serde_yaml::Value {
serde_yaml::from_str(source).expect("fixture parses as YAML")
}
#[test]
fn a_mapping_lowers_to_compact_json_with_types_preserved() {
let json = lower_artifact_config(
"murmur-tool-corpus",
&yaml(
"types:\n utterance:\n schema: { type: object, required: [text] }\n\
read_recent: { default: 20, max: 100 }\n",
),
)
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap(),
serde_json::json!({
"types": {"utterance": {"schema": {"type": "object", "required": ["text"]}}},
"read_recent": {"default": 20, "max": 100},
})
);
assert!(!json.contains(' '), "the compact form carries no padding");
}
#[test]
fn the_same_block_always_lowers_to_the_same_bytes() {
let block = yaml("b: 1\na: 2\nc: [3, 4]\n");
let first = lower_artifact_config("config-echo", &block).unwrap();
assert_eq!(first, r#"{"b":1,"a":2,"c":[3,4]}"#);
assert_eq!(first, lower_artifact_config("config-echo", &block).unwrap());
}
#[test]
fn an_empty_mapping_is_accepted() {
assert_eq!(
lower_artifact_config("config-echo", &yaml("{}")).unwrap(),
"{}"
);
}
#[test]
fn a_block_that_is_not_a_string_keyed_mapping_is_refused_by_artifact_name() {
for (source, expected) in [
("7", "a number"),
("[a, b]", "a sequence"),
("~", "an empty block"),
("'literal'", "a string"),
("true", "a boolean"),
] {
let err = match lower_artifact_config("config-echo", &yaml(source)) {
Ok(json) => panic!("'{source}' must be refused, lowered to {json}"),
Err(err) => err,
};
assert!(
matches!(err, RuntimeError::InvalidArtifactConfig { .. }),
"'{source}' must refuse as InvalidArtifactConfig, got: {err}"
);
let message = err.to_string();
assert!(
message.contains("config-echo"),
"the refusal must name the artifact: {message}"
);
assert!(
message.contains(expected),
"the refusal must say what was declared ({expected}): {message}"
);
}
}
#[test]
fn a_non_string_key_is_refused_and_quoted() {
let err = lower_artifact_config("config-echo", &yaml("1: x\n")).unwrap_err();
let message = err.to_string();
assert!(
message.contains("must be a string"),
"the refusal must state the rule: {message}"
);
assert!(
message.contains('1'),
"the refusal must quote the offending key: {message}"
);
}
#[test]
fn an_oversized_block_names_the_size_and_the_cap() {
let mut block = serde_yaml::Mapping::new();
block.insert("blob".into(), "x".repeat(70_000).into());
let err =
lower_artifact_config("config-echo", &serde_yaml::Value::Mapping(block)).unwrap_err();
let message = err.to_string();
assert!(
message.contains("70011"),
"the refusal must name the actual byte size: {message}"
);
assert!(
message.contains("65536"),
"the refusal must name the cap: {message}"
);
}
#[test]
fn a_block_exactly_at_the_cap_is_accepted() {
let mut block = serde_yaml::Mapping::new();
block.insert(
"blob".into(),
"x".repeat(MAX_ARTIFACT_CONFIG_BYTES - 11).into(),
);
let json =
lower_artifact_config("config-echo", &serde_yaml::Value::Mapping(block)).unwrap();
assert_eq!(json.len(), MAX_ARTIFACT_CONFIG_BYTES);
}
#[test]
fn names_are_reported_in_manifest_order_and_carry_no_values() {
let first = yaml("who: a\n");
let second = yaml("read_recent: { max: 100 }\n");
let names = configured_artifact_names([
("tool-a", Some(&first)),
("plain-tool", None),
("tool-b", Some(&second)),
])
.unwrap();
assert_eq!(names, vec!["tool-a".to_string(), "tool-b".to_string()]);
assert!(
!names.iter().any(|name| name.contains("read_recent")),
"a name list must carry nothing from inside a block"
);
}
#[test]
fn an_undeclared_artifact_set_reports_nothing() {
assert!(
configured_artifact_names([("plain-tool", None), ("bare-tool", None)])
.unwrap()
.is_empty()
);
}
#[test]
fn one_malformed_block_refuses_the_whole_resolution() {
let good = yaml("who: a\n");
let bad = yaml("7");
let err = configured_artifact_names([("tool-a", Some(&good)), ("tool-b", Some(&bad))])
.unwrap_err();
assert!(err.to_string().contains("tool-b"), "{err}");
}
}