use anyhow::{bail, Result};
use cviz::model::{ComponentNode, CompositionGraph, ExportInfo, InterfaceConnection};
use cviz::parse::component::{parse_component, parse_component_imports};
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
pub fn build_graph_from_components(
components: &[(String, PathBuf, Vec<u8>)],
) -> Result<(CompositionGraph, HashMap<u32, PathBuf>)> {
let n = components.len();
{
let mut seen: HashMap<&str, &PathBuf> = HashMap::with_capacity(n);
for (name, path, _) in components {
if let Some(prev_path) = seen.insert(name.as_str(), path) {
bail!(
"Name conflict: components at '{}' and '{}' both resolve to the name '{}'.\n\
Use the alias syntax (alias=path) to give them distinct names, e.g.:\n\
\t{}0={} {}1={}",
prev_path.display(),
path.display(),
name,
name,
prev_path.display(),
name,
path.display(),
);
}
}
}
struct CompInfo {
path: PathBuf,
name: String,
exports: Vec<String>,
imports: Vec<(String, Option<String>)>,
}
let mut comp_infos: Vec<CompInfo> = Vec::with_capacity(n);
for (name, path, bytes) in components {
let graph = parse_component(bytes)?;
let imports = parse_component_imports(bytes)?;
let exports: Vec<String> = graph.component_exports.keys().cloned().collect();
comp_infos.push(CompInfo {
path: path.clone(),
name: name.clone(),
exports,
imports,
});
}
let mut export_index: HashMap<String, usize> = HashMap::new();
for (comp_idx, info) in comp_infos.iter().enumerate() {
for export in &info.exports {
if export_index.insert(export.clone(), comp_idx).is_some() {
bail!(
"Ambiguous composition: multiple components export '{}'. \
Unable to decide correct composition here.",
export
);
}
}
}
struct ResolvedImport {
interface_name: String,
provider_comp_idx: usize,
import_fingerprint: Option<String>,
}
let mut resolved: Vec<Vec<ResolvedImport>> = (0..n).map(|_| Vec::new()).collect();
let mut unresolved: Vec<Vec<String>> = (0..n).map(|_| Vec::new()).collect();
let parsed_graphs: Vec<CompositionGraph> = components
.iter()
.map(|(_, _, bytes)| parse_component(bytes))
.collect::<Result<_>>()?;
for (comp_idx, info) in comp_infos.iter().enumerate() {
for (import_name, import_fp) in &info.imports {
match export_index.get(import_name) {
Some(&provider_idx) if provider_idx != comp_idx => {
let export_fp = parsed_graphs[provider_idx]
.component_exports
.get(import_name)
.and_then(|e| e.fingerprint.clone());
match (import_fp, &export_fp) {
(Some(ifp), Some(efp)) if ifp != efp => {
bail!(
"Type mismatch: '{}' imports '{}' but the types are incompatible.\n\
\timporter ({}): {}\n\
\texporter ({}): {}",
info.name,
import_name,
info.name,
ifp,
comp_infos[provider_idx].name,
efp,
);
}
_ => { }
}
resolved[comp_idx].push(ResolvedImport {
interface_name: import_name.clone(),
provider_comp_idx: provider_idx,
import_fingerprint: import_fp.clone(),
});
}
Some(_) => bail!(
"Component '{}' both imports and exports '{}'",
info.name,
import_name
),
None => {
unresolved[comp_idx].push(import_name.clone());
}
}
}
}
let mut in_degree: Vec<usize> = (0..n)
.map(|i| {
resolved[i]
.iter()
.map(|r| r.provider_comp_idx)
.collect::<BTreeSet<_>>()
.len()
})
.collect();
let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
let mut topo_order: Vec<usize> = Vec::with_capacity(n);
while let Some(idx) = queue.pop_front() {
topo_order.push(idx);
for other_idx in 0..n {
if resolved[other_idx]
.iter()
.any(|r| r.provider_comp_idx == idx)
{
in_degree[other_idx] -= 1;
if in_degree[other_idx] == 0 {
queue.push_back(other_idx);
}
}
}
}
if topo_order.len() != n {
bail!("Cyclic dependency detected among the provided components");
}
let mut comp_idx_to_node_id: Vec<u32> = vec![0; n];
for (topo_pos, &comp_idx) in topo_order.iter().enumerate() {
comp_idx_to_node_id[comp_idx] = topo_pos as u32;
}
let mut graph = CompositionGraph::new();
let mut node_paths: HashMap<u32, PathBuf> = HashMap::new();
for (topo_pos, &comp_idx) in topo_order.iter().enumerate() {
let info = &comp_infos[comp_idx];
let node_id = topo_pos as u32;
let mut node =
ComponentNode::new(format!("${}", info.name), comp_idx as u32, comp_idx as u32);
for res_import in &resolved[comp_idx] {
let provider_node_id = comp_idx_to_node_id[res_import.provider_comp_idx];
let fingerprint = res_import.import_fingerprint.clone().or_else(|| {
parsed_graphs[res_import.provider_comp_idx]
.component_exports
.get(&res_import.interface_name)
.and_then(|e| e.fingerprint.clone())
});
node.add_import(InterfaceConnection {
interface_name: res_import.interface_name.clone(),
source_instance: Some(provider_node_id),
is_host_import: false,
interface_type: None,
fingerprint,
});
}
for host_iface in &unresolved[comp_idx] {
node.add_import(InterfaceConnection {
interface_name: host_iface.clone(),
source_instance: Some(u32::MAX),
is_host_import: true,
interface_type: None,
fingerprint: None,
});
}
graph.add_node(node_id, node);
node_paths.insert(node_id, info.path.clone());
}
let internally_consumed: HashSet<&str> = resolved
.iter()
.flat_map(|v| v.iter())
.map(|r| r.interface_name.as_str())
.collect();
for (topo_pos, &comp_idx) in topo_order.iter().enumerate() {
let node_id = topo_pos as u32;
for export_name in &comp_infos[comp_idx].exports {
if !internally_consumed.contains(export_name.as_str()) {
let fingerprint = parsed_graphs[comp_idx]
.component_exports
.get(export_name)
.and_then(|e| e.fingerprint.clone());
graph.component_exports.insert(
export_name.clone(),
ExportInfo {
source_instance: node_id,
fingerprint,
ty: None,
},
);
}
}
}
Ok((graph, node_paths))
}
pub fn filename_from_path(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.replace(['.', '_'], "-")
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(path: &str, wat: &str) -> (String, PathBuf, Vec<u8>) {
let bytes = wat::parse_str(wat).expect("invalid WAT");
let pb = PathBuf::from(path);
(filename_from_path(&pb), pb, bytes)
}
fn mk_alias(alias: &str, path: &str, wat: &str) -> (String, PathBuf, Vec<u8>) {
let bytes = wat::parse_str(wat).expect("invalid WAT");
(alias.to_string(), PathBuf::from(path), bytes)
}
const WAT_PROVIDER_A: &str = r#"(component
(import "host:env/dep@0.1.0" (instance $dep
(export "get" (func (result u32)))
))
(alias export $dep "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:providers/a@0.1.0" (instance $out))
)"#;
const WAT_PROVIDER_B: &str = r#"(component
(import "host:env/dep@0.1.0" (instance $dep
(export "get" (func (result u32)))
))
(alias export $dep "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:providers/b@0.1.0" (instance $out))
)"#;
const WAT_PROVIDER_C: &str = r#"(component
(import "host:env/dep@0.1.0" (instance $dep
(export "get" (func (result u32)))
))
(alias export $dep "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:providers/c@0.1.0" (instance $out))
)"#;
const WAT_CONSUMER_FAN_IN: &str = r#"(component
(import "my:providers/a@0.1.0" (instance $a
(export "get" (func (result u32)))
))
(import "my:providers/b@0.1.0" (instance $b
(export "get" (func (result u32)))
))
(import "my:providers/c@0.1.0" (instance $c
(export "get" (func (result u32)))
))
(import "host:consumer/ctx@0.1.0" (instance $ctx
(export "write" (func (param "msg" string)))
))
(alias export $a "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:consumer/app@0.1.0" (instance $out))
)"#;
const WAT_SIMPLE_CONSUMER: &str = r#"(component
(import "my:providers/a@0.1.0" (instance $a
(export "get" (func (result u32)))
))
(alias export $a "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:consumer/app@0.1.0" (instance $out))
)"#;
const WAT_PROVIDER_A_DUP: &str = r#"(component
(import "host:env/dep@0.1.0" (instance $dep
(export "get" (func (result u32)))
))
(alias export $dep "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:providers/a@0.1.0" (instance $out))
)"#;
const WAT_PROVIDER_V1: &str = r#"(component
(import "host:env/dep@0.1.0" (instance $dep
(export "do-it" (func (result u32)))
))
(alias export $dep "do-it" (func $f))
(instance $out (export "do-it" (func $f)))
(export "my:shared/iface@0.1.0" (instance $out))
)"#;
const WAT_CONSUMER_MISMATCHED: &str = r#"(component
(import "my:shared/iface@0.1.0" (instance $iface
(export "do-it" (func (result string)))
))
(alias export $iface "do-it" (func $f))
(instance $out (export "do-it" (func $f)))
(export "my:consumer/output@0.1.0" (instance $out))
)"#;
const WAT_CYCLE_A: &str = r#"(component
(import "my:cycle/b@0.1.0" (instance $b
(export "go" (func (result u32)))
))
(alias export $b "go" (func $f))
(instance $out (export "go" (func $f)))
(export "my:cycle/a@0.1.0" (instance $out))
)"#;
const WAT_CYCLE_B: &str = r#"(component
(import "my:cycle/a@0.1.0" (instance $a
(export "go" (func (result u32)))
))
(alias export $a "go" (func $f))
(instance $out (export "go" (func $f)))
(export "my:cycle/b@0.1.0" (instance $out))
)"#;
const WAT_SELF_IMPORT: &str = r#"(component
(import "my:shared/iface@0.1.0" (instance $iface
(export "get" (func (result u32)))
))
(alias export $iface "get" (func $f))
(instance $out (export "get" (func $f)))
(export "my:shared/iface@0.1.0" (instance $out))
)"#;
#[test]
fn simple_chain_resolves() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("consumer.wasm", WAT_SIMPLE_CONSUMER),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
assert_eq!(graph.nodes.len(), 2);
assert_eq!(node_paths.len(), 2);
let consumer_node = graph
.nodes
.values()
.find(|n| n.name.contains("consumer"))
.expect("consumer node not found");
let resolved_import = consumer_node
.imports
.iter()
.find(|i| i.interface_name == "my:providers/a@0.1.0" && !i.is_host_import)
.expect("consumer should have a resolved import for my:providers/a@0.1.0");
let provider_node = graph
.nodes
.values()
.find(|n| n.name.contains("provider-a"))
.expect("provider-a node not found");
assert_eq!(
resolved_import.source_instance.unwrap(),
*graph
.nodes
.iter()
.find(|(_, n)| n.name == provider_node.name)
.unwrap()
.0,
"consumer's import should point to provider-a's node id"
);
Ok(())
}
#[test]
fn fan_in_all_deps_wired() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("provider-b.wasm", WAT_PROVIDER_B),
mk("provider-c.wasm", WAT_PROVIDER_C),
mk("consumer.wasm", WAT_CONSUMER_FAN_IN),
];
let (graph, _) = build_graph_from_components(&comps)?;
assert_eq!(graph.nodes.len(), 4);
let consumer_node = graph
.nodes
.values()
.find(|n| n.name.contains("consumer"))
.expect("consumer node not found");
for iface in &[
"my:providers/a@0.1.0",
"my:providers/b@0.1.0",
"my:providers/c@0.1.0",
] {
assert!(
consumer_node
.imports
.iter()
.any(|i| i.interface_name == *iface && !i.is_host_import),
"consumer should have resolved import for {iface}"
);
}
assert!(
consumer_node
.imports
.iter()
.any(|i| i.interface_name == "host:consumer/ctx@0.1.0" && i.is_host_import),
"consumer should have host import for host:consumer/ctx@0.1.0"
);
Ok(())
}
#[test]
fn topological_order_providers_get_lower_ids() -> anyhow::Result<()> {
let comps = vec![
mk("consumer.wasm", WAT_SIMPLE_CONSUMER),
mk("provider-a.wasm", WAT_PROVIDER_A),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let provider_id = *graph
.nodes
.iter()
.find(|(_, n)| n.name.contains("provider-a"))
.expect("provider-a not found")
.0;
let consumer_id = *graph
.nodes
.iter()
.find(|(_, n)| n.name.contains("consumer"))
.expect("consumer not found")
.0;
assert!(
provider_id < consumer_id,
"provider node id ({provider_id}) should be less than consumer node id ({consumer_id})"
);
assert!(node_paths.contains_key(&provider_id));
assert!(node_paths.contains_key(&consumer_id));
Ok(())
}
#[test]
fn host_imports_become_host_connections() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("consumer.wasm", WAT_CONSUMER_FAN_IN),
mk("provider-b.wasm", WAT_PROVIDER_B),
mk("provider-c.wasm", WAT_PROVIDER_C),
];
let (graph, _) = build_graph_from_components(&comps)?;
let providers_with_host_dep: Vec<_> = graph
.nodes
.values()
.filter(|n| {
n.imports
.iter()
.any(|i| i.interface_name == "host:env/dep@0.1.0" && i.is_host_import)
})
.collect();
assert_eq!(
providers_with_host_dep.len(),
3,
"all three providers should have host:env/dep@0.1.0 as a host import"
);
Ok(())
}
#[test]
fn graph_level_exports_are_set() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("consumer.wasm", WAT_SIMPLE_CONSUMER),
];
let (graph, _) = build_graph_from_components(&comps)?;
assert!(
graph
.component_exports
.contains_key("my:consumer/app@0.1.0"),
"my:consumer/app@0.1.0 should be a graph-level export"
);
assert!(
!graph.component_exports.contains_key("my:providers/a@0.1.0"),
"my:providers/a@0.1.0 is consumed internally and must not be a graph-level export"
);
Ok(())
}
#[test]
fn roundtrip_simple_chain_wac() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("consumer.wasm", WAT_SIMPLE_CONSUMER),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&[],
Some(&node_paths),
"test:pkg",
)?;
let wac = out.wac;
assert!(out.diagnostics.is_empty());
assert!(
wac.contains("package test:pkg;"),
"missing package line:\n{wac}"
);
assert!(
wac.contains("let provider-a = new my:provider-a {"),
"missing provider-a instantiation:\n{wac}"
);
assert!(
wac.contains("let consumer = new my:consumer {"),
"missing consumer instantiation:\n{wac}"
);
assert!(
wac.contains(r#""my:providers/a@0.1.0": provider-a["my:providers/a@0.1.0"]"#),
"consumer should wire provider-a for my:providers/a@0.1.0:\n{wac}"
);
assert!(
wac.contains(r#"export consumer["my:consumer/app@0.1.0"];"#),
"missing export line:\n{wac}"
);
assert_eq!(out.wac_deps.len(), 2, "expected 2 wac_deps entries");
let paths: Vec<String> = out
.wac_deps
.values()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
paths.iter().any(|p| p == "provider-a.wasm"),
"provider-a.wasm missing from wac_deps"
);
assert!(
paths.iter().any(|p| p == "consumer.wasm"),
"consumer.wasm missing from wac_deps"
);
Ok(())
}
#[test]
fn roundtrip_fan_in_wac() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("provider-b.wasm", WAT_PROVIDER_B),
mk("provider-c.wasm", WAT_PROVIDER_C),
mk("consumer.wasm", WAT_CONSUMER_FAN_IN),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&[],
Some(&node_paths),
"test:pkg",
)?;
let wac = out.wac;
assert!(out.diagnostics.is_empty());
for name in &["provider-a", "provider-b", "provider-c", "consumer"] {
assert!(
wac.contains(&format!("let {name} = new my:{name} {{")),
"missing {name} instantiation:\n{wac}"
);
}
for (iface, var) in &[
("my:providers/a@0.1.0", "provider-a"),
("my:providers/b@0.1.0", "provider-b"),
("my:providers/c@0.1.0", "provider-c"),
] {
assert!(
wac.contains(&format!(r#""{iface}": {var}["{iface}"]"#)),
"consumer should wire {iface} from {var}:\n{wac}"
);
}
assert!(
wac.contains(r#"export consumer["my:consumer/app@0.1.0"];"#),
"missing export line:\n{wac}"
);
assert_eq!(out.wac_deps.len(), 4, "expected 4 wac_deps entries");
Ok(())
}
#[test]
fn roundtrip_internally_consumed_iface_not_exported() -> anyhow::Result<()> {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("consumer.wasm", WAT_SIMPLE_CONSUMER),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&[],
Some(&node_paths),
"test:pkg",
)?;
let wac = out.wac;
assert!(
!wac.contains(r#"export provider-a["my:providers/a@0.1.0"];"#),
"internally-consumed interface must not be exported:\n{wac}"
);
assert!(
wac.contains(r#"export consumer["my:consumer/app@0.1.0"];"#),
"graph-level export must be present:\n{wac}"
);
Ok(())
}
#[test]
fn roundtrip_wac_deps_contain_correct_paths() -> anyhow::Result<()> {
let comps = vec![
mk("path/to/provider-a.wasm", WAT_PROVIDER_A),
mk("path/to/consumer.wasm", WAT_SIMPLE_CONSUMER),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&[],
Some(&node_paths),
"test:pkg",
)?;
let paths: HashSet<String> = out
.wac_deps
.values()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
paths.contains("path/to/provider-a.wasm"),
"expected path/to/provider-a.wasm in wac_deps, got: {paths:?}"
);
assert!(
paths.contains("path/to/consumer.wasm"),
"expected path/to/consumer.wasm in wac_deps, got: {paths:?}"
);
Ok(())
}
#[test]
fn roundtrip_fan_in_middleware_wired_correctly() -> anyhow::Result<()> {
use crate::parse::config::{Injection, SpliceRule};
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("provider-b.wasm", WAT_PROVIDER_B),
mk("provider-c.wasm", WAT_PROVIDER_C),
mk("consumer.wasm", WAT_CONSUMER_FAN_IN),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let rules = vec![SpliceRule::Before {
interface: "my:providers/a@0.1.0".to_string(),
provider_name: Some("provider-a".to_string()),
provider_alias: None,
inject: vec![Injection {
name: "a-middleware".to_string(),
adapter_info: None,
tier: None,
builtin: None,
builtin_config: Default::default(),
config_as_wave: None,
config_provider_path: None,
path: None,
}],
}];
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&rules,
Some(&node_paths),
"test:pkg",
)?;
let wac = out.wac;
assert!(
wac.contains(r#""my:providers/a@0.1.0": provider-a["my:providers/a@0.1.0"]"#),
"a-middleware should be wired from provider-a:\n{wac}"
);
assert!(
wac.contains("let a-middleware = new my:a-middleware {"),
"a-middleware must be instantiated:\n{wac}"
);
assert!(
wac.contains(r#""my:providers/a@0.1.0": a-middleware["my:providers/a@0.1.0"]"#),
"consumer should receive my:providers/a@0.1.0 through a-middleware, not raw provider-a:\n{wac}"
);
assert!(
wac.contains(r#""my:providers/b@0.1.0": provider-b["my:providers/b@0.1.0"]"#),
"consumer should still wire provider-b directly:\n{wac}"
);
assert!(
wac.contains(r#""my:providers/c@0.1.0": provider-c["my:providers/c@0.1.0"]"#),
"consumer should still wire provider-c directly:\n{wac}"
);
Ok(())
}
#[test]
fn tier1_shared_mdl_across_rules_produces_distinct_adapters() -> anyhow::Result<()> {
use crate::parse::config::{AdapterInjectionInfo, Injection, SpliceRule};
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("provider-b.wasm", WAT_PROVIDER_B),
mk("provider-c.wasm", WAT_PROVIDER_C),
mk("consumer.wasm", WAT_CONSUMER_FAN_IN),
];
let (graph, node_paths) = build_graph_from_components(&comps)?;
let mdl_path = "/tmp/tracing.wasm".to_string();
let write_minimal = |name: &str| -> String {
let bytes = wat::parse_str("(component)").expect("compile minimal component");
let path = std::env::temp_dir().join(format!(
"splicer-test-{}-{}.wasm",
name,
std::process::id()
));
std::fs::write(&path, bytes).expect("write tempfile");
path.to_string_lossy().into_owned()
};
let adapter_a_path = write_minimal("adapter-a");
let adapter_b_path = write_minimal("adapter-b");
let mk_rule = |iface: &str, provider: &str, adapter_path: &str| SpliceRule::Before {
interface: iface.to_string(),
provider_name: Some(provider.to_string()),
provider_alias: None,
inject: vec![Injection {
name: "tracing".to_string(),
path: Some(mdl_path.clone()),
builtin: None,
builtin_config: Default::default(),
config_as_wave: None,
config_provider_path: None,
adapter_info: Some(AdapterInjectionInfo {
adapter_path: adapter_path.to_string(),
matched_hook_interfaces: vec![
"splicer:tier1/before".to_string(),
"splicer:tier1/after".to_string(),
],
}),
tier: None,
}],
};
let rules = vec![
mk_rule("my:providers/a@0.1.0", "provider-a", &adapter_a_path),
mk_rule("my:providers/b@0.1.0", "provider-b", &adapter_b_path),
];
let out = crate::wac::generate_wac(
HashMap::new(),
"",
&graph,
&rules,
Some(&node_paths),
"test:pkg",
)?;
let wac = out.wac;
assert!(
wac.contains("my:tracing-adapter-providers-a-v0-v1-v0"),
"expected per-interface adapter pkg for providers/a:\n{wac}"
);
assert!(
wac.contains("my:tracing-adapter-providers-b-v0-v1-v0"),
"expected per-interface adapter pkg for providers/b:\n{wac}"
);
let real_let_count = wac.match_indices("let tracing = new my:tracing").count();
assert_eq!(
real_let_count, 1,
"`let tracing = new my:tracing` should appear exactly once; saw {real_let_count}:\n{wac}"
);
let path_for = |pkg: &str| {
out.wac_deps
.get(pkg)
.map(|p| p.to_string_lossy().into_owned())
};
assert_eq!(
path_for("my:tracing-adapter-providers-a-v0-v1-v0").as_deref(),
Some(adapter_a_path.as_str()),
);
assert_eq!(
path_for("my:tracing-adapter-providers-b-v0-v1-v0").as_deref(),
Some(adapter_b_path.as_str()),
);
assert_eq!(path_for("my:tracing").as_deref(), Some(mdl_path.as_str()));
Ok(())
}
#[test]
fn error_ambiguous_export() {
let comps = vec![
mk("provider-a.wasm", WAT_PROVIDER_A),
mk("provider-a-dup.wasm", WAT_PROVIDER_A_DUP),
];
let result = build_graph_from_components(&comps);
assert!(result.is_err(), "expected Ambiguous composition error");
let err = result.err().unwrap();
assert!(
err.to_string().contains("Ambiguous composition"),
"expected 'Ambiguous composition' in error, got: {err}"
);
}
#[test]
fn error_cyclic_dependency() {
let comps = vec![
mk("cycle-a.wasm", WAT_CYCLE_A),
mk("cycle-b.wasm", WAT_CYCLE_B),
];
let result = build_graph_from_components(&comps);
assert!(result.is_err(), "expected cyclic dependency error");
let err = result.err().unwrap();
assert!(
err.to_string().to_lowercase().contains("cyclic"),
"expected 'cyclic' in error, got: {err}"
);
}
#[test]
fn error_self_import_and_export() {
let comps = vec![mk("self-import.wasm", WAT_SELF_IMPORT)];
let result = build_graph_from_components(&comps);
assert!(result.is_err(), "expected both-imports-and-exports error");
let err = result.err().unwrap();
assert!(
err.to_string().contains("both imports and exports"),
"expected 'both imports and exports' in error, got: {err}"
);
}
#[test]
fn error_duplicate_stem_names() {
let comps = vec![
mk("dir0/file.wasm", WAT_PROVIDER_A),
mk("dir1/file.wasm", WAT_PROVIDER_B),
];
let result = build_graph_from_components(&comps);
assert!(result.is_err(), "expected name-conflict error");
let err = result.err().unwrap().to_string();
assert!(
err.contains("Name conflict") && err.contains("file"),
"expected 'Name conflict' mentioning the stem, got: {err}"
);
assert!(
err.contains("alias"),
"error should mention alias syntax, got: {err}"
);
}
#[test]
fn error_duplicate_explicit_aliases() {
let comps = vec![
mk_alias("mycomp", "dir0/a.wasm", WAT_PROVIDER_A),
mk_alias("mycomp", "dir1/b.wasm", WAT_PROVIDER_B),
];
let result = build_graph_from_components(&comps);
assert!(
result.is_err(),
"expected name-conflict error for duplicate aliases"
);
let err = result.err().unwrap().to_string();
assert!(
err.contains("Name conflict") && err.contains("mycomp"),
"expected 'Name conflict' mentioning 'mycomp', got: {err}"
);
}
#[test]
fn alias_disambiguates_same_stem() {
let comps = vec![
mk_alias("provider-first", "dir0/file.wasm", WAT_PROVIDER_A),
mk_alias("consumer-app", "dir1/file.wasm", WAT_SIMPLE_CONSUMER),
];
let result = build_graph_from_components(&comps);
assert!(
result.is_ok(),
"aliases should resolve the name conflict: {:?}",
result.err()
);
let (graph, _) = result.unwrap();
assert_eq!(graph.nodes.len(), 2);
assert!(
graph
.nodes
.values()
.any(|n| n.name.contains("provider-first")),
"expected node named provider-first"
);
assert!(
graph
.nodes
.values()
.any(|n| n.name.contains("consumer-app")),
"expected node named consumer-app"
);
}
#[test]
fn error_type_mismatch() {
let comps = vec![
mk("provider-v1.wasm", WAT_PROVIDER_V1),
mk("consumer-mismatched.wasm", WAT_CONSUMER_MISMATCHED),
];
let result = build_graph_from_components(&comps);
match result {
Err(e) => assert!(
e.to_string().contains("Type mismatch") || e.to_string().contains("incompatible"),
"expected type-mismatch error, got: {e}"
),
Ok(_) => {
}
}
}
}