use anyhow::bail;
use serde::Deserialize;
use std::collections::HashMap;
pub fn parse_yaml(yaml_str: &str) -> anyhow::Result<Vec<SpliceRule>> {
let config: ConfigFile = serde_yaml::from_str(yaml_str)?;
config.validate()?;
Ok(config.into_splice_rules())
}
#[derive(Debug, Deserialize)]
pub struct ConfigFile {
pub version: u32,
pub rules: Vec<YamlRule>,
}
#[derive(Debug, Deserialize)]
pub struct YamlRule {
before: Option<YamlStrategyBefore>,
between: Option<YamlStrategyBetween>,
inject: Vec<Injection>,
}
#[derive(Debug, Deserialize)]
pub struct YamlStrategyBefore {
interface: String,
provider: Option<YamlProviderOpt>,
}
#[derive(Debug, Deserialize)]
pub struct YamlStrategyBetween {
inner: YamlProviderReq,
outer: YamlProviderReq,
interface: String,
}
#[derive(Debug, Deserialize)]
pub struct YamlProviderReq {
name: String,
alias: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct YamlProviderOpt {
name: Option<String>,
alias: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Injection {
pub name: String,
pub path: Option<String>,
}
#[derive(Debug)]
pub enum SpliceRule {
Before {
interface: String,
provider_name: Option<String>,
provider_alias: Option<String>,
inject: Vec<Injection>,
},
Between {
interface: String,
inner_name: String,
inner_alias: Option<String>,
outer_name: String,
outer_alias: Option<String>,
inject: Vec<Injection>,
},
}
impl ConfigFile {
pub fn validate(&self) -> anyhow::Result<()> {
if self.version != 1 {
bail!(
"unsupported config version {}: only version 1 is supported",
self.version
);
}
let mut seen_names: HashMap<&str, usize> = HashMap::new();
for (i, rule) in self.rules.iter().enumerate() {
let rule_num = i + 1;
match (&rule.before, &rule.between) {
(Some(_), Some(_)) => {
bail!("rule {rule_num}: a rule may specify 'before' or 'between', not both")
}
(None, None) => {
bail!("rule {rule_num}: a rule must specify either 'before' or 'between'")
}
_ => {}
}
let interface = if let Some(b) = &rule.before {
&b.interface
} else if let Some(bw) = &rule.between {
&bw.interface
} else {
unreachable!()
};
if interface.is_empty() {
bail!("rule {rule_num}: 'interface' must not be empty");
}
if let Some(before) = &rule.before {
if let Some(prov) = &before.provider {
if prov.name.as_deref() == Some("") {
bail!(
"rule {rule_num}: provider 'name' must not be empty if specified \
(omit the key to leave it unset)"
);
}
}
}
if let Some(between) = &rule.between {
if between.inner.name == between.outer.name {
bail!(
"rule {rule_num} (between): 'inner' and 'outer' must name different \
instances, but both are '{}'",
between.inner.name
);
}
}
if rule.inject.is_empty() {
bail!("rule {rule_num}: 'inject' list must contain at least one entry");
}
for (j, inj) in rule.inject.iter().enumerate() {
let inj_num = j + 1;
if inj.name.is_empty() {
bail!("rule {rule_num}, injection {inj_num}: injection name must not be empty");
}
if inj.path.as_deref() == Some("") {
bail!(
"rule {rule_num}, injection '{}': 'path' must not be empty if specified \
(omit the key to leave it unset)",
inj.name
);
}
if let Some(first_rule) = seen_names.get(inj.name.as_str()) {
bail!(
"injection name '{}' is used in rule {rule_num} but was already declared \
in rule {first_rule}; each injection must have a globally unique name",
inj.name
);
}
seen_names.insert(&inj.name, rule_num);
}
}
Ok(())
}
pub fn into_splice_rules(self) -> Vec<SpliceRule> {
self.rules
.into_iter()
.map(
|YamlRule {
before,
between,
inject,
}| {
if let Some(YamlStrategyBefore {
interface,
provider,
}) = before
{
SpliceRule::Before {
interface,
provider_name: provider.as_ref().and_then(|p| p.name.clone()),
provider_alias: provider.and_then(|p| p.alias),
inject,
}
} else if let Some(YamlStrategyBetween {
interface,
inner,
outer,
}) = between
{
SpliceRule::Between {
interface,
inner_name: inner.name,
inner_alias: inner.alias,
outer_name: outer.name,
outer_alias: outer.alias,
inject,
}
} else {
unreachable!("validate() guarantees exactly one strategy per rule")
}
},
)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_before_rule() {
let yaml = r#"
version: 1
rules:
- before:
interface: wasi:http/handler@0.3.0
provider:
name: srv-b
inject:
- name: middleware-a
"#;
let rules = parse_yaml(yaml).unwrap();
assert_eq!(rules.len(), 1);
let SpliceRule::Before {
interface,
provider_name,
provider_alias,
inject,
} = &rules[0]
else {
panic!("expected Before rule");
};
assert_eq!(interface, "wasi:http/handler@0.3.0");
assert_eq!(provider_name.as_deref(), Some("srv-b"));
assert!(provider_alias.is_none());
assert_eq!(inject.len(), 1);
assert_eq!(inject[0].name, "middleware-a");
assert!(inject[0].path.is_none());
}
#[test]
fn parse_before_rule_no_provider() {
let yaml = r#"
version: 1
rules:
- before:
interface: wasi:http/handler@0.3.0
inject:
- name: mw
"#;
let rules = parse_yaml(yaml).unwrap();
assert_eq!(rules.len(), 1);
let SpliceRule::Before {
provider_name,
provider_alias,
..
} = &rules[0]
else {
panic!("expected Before rule");
};
assert!(provider_name.is_none());
assert!(provider_alias.is_none());
}
#[test]
fn parse_between_rule() {
let yaml = r#"
version: 1
rules:
- between:
interface: wasi:http/handler@0.3.0
inner:
name: srv-b
alias: renamed-b
outer:
name: srv
inject:
- name: mw-a
- name: mw-b
path: /tmp/mw-b.wasm
"#;
let rules = parse_yaml(yaml).unwrap();
assert_eq!(rules.len(), 1);
let SpliceRule::Between {
interface,
inner_name,
inner_alias,
outer_name,
outer_alias,
inject,
} = &rules[0]
else {
panic!("expected Between rule");
};
assert_eq!(interface, "wasi:http/handler@0.3.0");
assert_eq!(inner_name, "srv-b");
assert_eq!(inner_alias.as_deref(), Some("renamed-b"));
assert_eq!(outer_name, "srv");
assert!(outer_alias.is_none());
assert_eq!(inject.len(), 2);
assert_eq!(inject[1].path.as_deref(), Some("/tmp/mw-b.wasm"));
}
#[test]
fn parse_multi_rule() {
let yaml = r#"
version: 1
rules:
- before:
interface: wasi:http/handler@0.3.0
inject:
- name: first
- between:
interface: wasi:http/handler@0.3.0
inner:
name: srv-b
outer:
name: srv
inject:
- name: second
"#;
let rules = parse_yaml(yaml).unwrap();
assert_eq!(rules.len(), 2);
assert!(matches!(rules[0], SpliceRule::Before { .. }));
assert!(matches!(rules[1], SpliceRule::Between { .. }));
let SpliceRule::Before { inject: inj0, .. } = &rules[0] else {
unreachable!()
};
let SpliceRule::Between { inject: inj1, .. } = &rules[1] else {
unreachable!()
};
assert_eq!(inj0[0].name, "first");
assert_eq!(inj1[0].name, "second");
}
#[test]
fn parse_missing_interface() {
let yaml = r#"
version: 1
rules:
- before:
provider:
name: srv-b
inject:
- name: mw
"#;
let result = parse_yaml(yaml);
assert!(
result.is_err(),
"expected parse error for missing interface field"
);
}
#[test]
fn parse_unknown_version() {
let yaml = r#"
version: 99
rules: []
"#;
let err = parse_yaml(yaml).unwrap_err().to_string();
assert!(
err.contains("unsupported config version"),
"unexpected error: {err}"
);
}
fn assert_err(yaml: &str, expected_fragment: &str) {
let err = parse_yaml(yaml).unwrap_err().to_string();
assert!(
err.contains(expected_fragment),
"expected error containing {expected_fragment:?}, got: {err}"
);
}
#[test]
fn validate_both_before_and_between() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
between:
interface: wasi:http/handler
inner:
name: a
outer:
name: b
inject:
- name: mw
"#,
"'before' or 'between', not both",
);
}
#[test]
fn validate_neither_before_nor_between() {
assert_err(
r#"
version: 1
rules:
- inject:
- name: mw
"#,
"either 'before' or 'between'",
);
}
#[test]
fn validate_empty_inject_list() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
inject: []
"#,
"'inject' list must contain at least one entry",
);
}
#[test]
fn validate_empty_injection_name() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
inject:
- name: ""
"#,
"injection name must not be empty",
);
}
#[test]
fn validate_empty_injection_path() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
inject:
- name: mw
path: ""
"#,
"'path' must not be empty if specified",
);
}
#[test]
fn validate_empty_interface_name() {
assert_err(
r#"
version: 1
rules:
- before:
interface: ""
inject:
- name: mw
"#,
"'interface' must not be empty",
);
}
#[test]
fn validate_empty_before_provider_name() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
provider:
name: ""
inject:
- name: mw
"#,
"provider 'name' must not be empty if specified",
);
}
#[test]
fn validate_between_same_inner_outer() {
assert_err(
r#"
version: 1
rules:
- between:
interface: wasi:http/handler
inner:
name: srv
outer:
name: srv
inject:
- name: mw
"#,
"'inner' and 'outer' must name different instances",
);
}
#[test]
fn validate_duplicate_injection_name_across_rules() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
inject:
- name: mw-a
- before:
interface: wasi:logging/log
inject:
- name: mw-a
"#,
"injection name 'mw-a' is used in rule 2 but was already declared in rule 1",
);
}
#[test]
fn validate_duplicate_injection_name_within_rule() {
assert_err(
r#"
version: 1
rules:
- before:
interface: wasi:http/handler
inject:
- name: mw-a
- name: mw-a
"#,
"injection name 'mw-a' is used in rule 1 but was already declared in rule 1",
);
}
}