From ccd19c190fac522013315e6ee0a6fcb0b0bfe9c5 Mon Sep 17 00:00:00 2001 From: markfisher Date: Fri, 20 Mar 2026 16:09:10 -0400 Subject: [PATCH 1/3] add support for labels on component definitions also: * add metadata for selection evaluation, includes the labels * add list_components(selector) on Runtime and ComponentInvoker * add optional --selector on composable shell this will enable selectors for scope and interceptor config Signed-off-by: markfisher --- src/composition/registry.rs | 39 ++++- src/composition/wit.rs | 14 +- src/config/handlers.rs | 45 +++++- src/config/types.rs | 298 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- src/main.rs | 55 +++++-- src/messaging/activator.rs | 8 +- src/runtime/host.rs | 44 +++++- src/runtime/mod.rs | 18 +-- src/types.rs | 54 ++++++- tests/host_capabilities.rs | 4 +- 11 files changed, 525 insertions(+), 58 deletions(-) diff --git a/src/composition/registry.rs b/src/composition/registry.rs index 86ccca6..fb7e3d7 100644 --- a/src/composition/registry.rs +++ b/src/composition/registry.rs @@ -9,8 +9,10 @@ use wasmtime::component::{HasData, Linker}; use super::composer::Composer; use super::graph::{ComponentGraph, Edge, Node}; -use super::wit::{ComponentMetadata, Parser}; -use crate::types::{CapabilityDefinition, ComponentDefinition, ComponentState, Function}; +use super::wit::Parser; +use crate::types::{ + CapabilityDefinition, ComponentDefinition, ComponentMetadata, ComponentState, Function, +}; /// Trait implemented by host capability instances. /// @@ -160,10 +162,12 @@ pub struct ComponentSpec { pub name: String, pub namespace: Option, pub package: Option, + pub labels: HashMap, pub bytes: Arc<[u8]>, pub imports: Vec, pub exports: Vec, pub capabilities: Vec, + pub dependents: Vec, pub functions: HashMap, } @@ -271,7 +275,7 @@ pub async fn build_registries( let mut built_components = HashMap::new(); - for node_index in sorted_indices { + for &node_index in &sorted_indices { if let Node::Component(definition) = &component_graph[node_index] { let temp_component_registry = ComponentRegistry { components: Arc::new(built_components.clone()), @@ -289,6 +293,20 @@ pub async fn build_registries( } } + // Compute dependents from graph edges + for &node_index in &sorted_indices { + if let Node::Component(definition) = &component_graph[node_index] { + for (dep_index, edge) in component_graph.get_dependencies(node_index) { + if matches!(edge, Edge::Dependency) + && let Node::Component(dep_def) = &component_graph[dep_index] + && let Some(spec) = built_components.get_mut(&dep_def.name) + { + spec.dependents.push(definition.name.clone()); + } + } + } + } + Ok(( ComponentRegistry { components: Arc::new(built_components), @@ -509,6 +527,15 @@ async fn process_component( let mut all_capabilities = HashSet::new(); + let requester_metadata = ComponentMetadata { + name: definition.name.clone(), + namespace: metadata.namespace.clone(), + package: metadata.name.clone(), + labels: definition.labels.clone(), + dependents: None, + exports: exports.clone(), + }; + let dependencies: Vec<_> = component_graph.get_dependencies(node_index).collect(); for (dependency_node_index, edge) in &dependencies { let dependency_node = &component_graph[*dependency_node_index]; @@ -517,7 +544,7 @@ async fn process_component( let component_spec = component_registry.get_required_import( dependency_def, definition, - &metadata, + &requester_metadata, )?; if matches!(edge, Edge::Interceptor(_)) && is_advice_component(&exports) { @@ -614,11 +641,13 @@ async fn process_component( Ok(ComponentSpec { name: definition.name.clone(), namespace: metadata.namespace, - package: metadata.package, + package: metadata.name, + labels: definition.labels.clone(), bytes: Arc::from(bytes), imports, exports, capabilities: all_capabilities.into_iter().collect(), + dependents: Vec::new(), functions, }) } diff --git a/src/composition/wit.rs b/src/composition/wit.rs index 8a752a8..bafc2c3 100644 --- a/src/composition/wit.rs +++ b/src/composition/wit.rs @@ -6,9 +6,9 @@ use wit_parser::{Resolve, Type}; use crate::types::{Function, FunctionParam, Interface}; #[derive(Debug, Clone)] -pub struct ComponentMetadata { +pub struct PackageMetadata { pub namespace: Option, - pub package: Option, + pub name: Option, } pub struct Parser; @@ -18,7 +18,7 @@ impl Parser { pub fn parse( component_bytes: &[u8], ) -> Result<( - ComponentMetadata, + PackageMetadata, Vec, Vec, HashMap, @@ -36,14 +36,14 @@ impl Parser { let component_metadata = if let Some(package_id) = &world.package { let package = resolve.packages.get(*package_id).unwrap(); let package_name = &package.name; - ComponentMetadata { + PackageMetadata { namespace: Some(package_name.namespace.clone()), - package: Some(package_name.name.clone()), + name: Some(package_name.name.clone()), } } else { - ComponentMetadata { + PackageMetadata { namespace: None, - package: None, + name: None, } }; diff --git a/src/config/handlers.rs b/src/config/handlers.rs index c8e39ea..2e3a2b0 100644 --- a/src/config/handlers.rs +++ b/src/config/handlers.rs @@ -25,7 +25,15 @@ impl ConfigHandler for ComponentConfigHandler { fn claimed_properties(&self) -> HashMap<&str, &[&str]> { HashMap::from([( "component", - ["uri", "scope", "imports", "interceptors", "config"].as_slice(), + [ + "uri", + "scope", + "imports", + "interceptors", + "config", + "labels", + ] + .as_slice(), )]) } @@ -48,6 +56,7 @@ impl ConfigHandler for ComponentConfigHandler { let imports = take_string_array(&mut properties, "imports").map_err(ctx)?; let interceptors = take_string_array(&mut properties, "interceptors").map_err(ctx)?; let config = take_object(&mut properties, "config").map_err(ctx)?; + let labels = take_string_map(&mut properties, "labels").map_err(ctx)?; if !properties.is_empty() { let unknown: Vec<_> = properties.keys().collect(); @@ -63,6 +72,7 @@ impl ConfigHandler for ComponentConfigHandler { imports, interceptors, config, + labels, }); Ok(()) } @@ -225,3 +235,36 @@ fn take_object( None => Ok(HashMap::new()), } } + +fn take_string_map( + properties: &mut PropertyMap, + key: &str, +) -> Result, PropertyError> { + match properties.remove(key) { + Some(serde_json::Value::Object(map)) => { + let mut result = HashMap::new(); + for (k, v) in map { + let s = match v { + serde_json::Value::String(s) => s, + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + got => { + return Err(PropertyError::TypeMismatch { + key: format!("{key}.{k}"), + expected: "a scalar value", + got, + }); + } + }; + result.insert(k, s); + } + Ok(result) + } + Some(got) => Err(PropertyError::TypeMismatch { + key: key.into(), + expected: "an object/table of strings", + got, + }), + None => Ok(HashMap::new()), + } +} diff --git a/src/config/types.rs b/src/config/types.rs index 2979348..ae55425 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -24,6 +24,8 @@ pub enum Operator { NotEquals(String), In(Vec), NotIn(Vec), + Contains(String), + NotContains(String), Exists, DoesNotExist, } @@ -43,6 +45,28 @@ pub struct Selector { } impl Selector { + /// Parse a selector string. + /// + /// Comma-separated conditions, AND semantics. Supported expressions: + /// - equality/inequality: `key=val`, `key!=val` + /// - set membership: `key in (a,b,c)`, `key notin (a,b,c)` + /// - substring or list element match: `key contains val`, `key notcontains val` + /// - key exists/does-not-exist: `key`, `!key` + pub fn parse(s: &str) -> Result { + let mut conditions = Vec::new(); + for part in split_conditions(s) { + let part = part.trim(); + if part.is_empty() { + continue; + } + conditions.push(parse_condition(part)?); + } + if conditions.is_empty() { + anyhow::bail!("empty selector"); + } + Ok(Self { conditions }) + } + pub fn matches(&self, properties: &HashMap>) -> bool { self.conditions.iter().all(|c| c.matches(properties)) } @@ -63,12 +87,132 @@ impl Condition { Operator::NotIn(values) => properties .get(&self.key) .is_some_and(|v| v.as_ref().is_some_and(|v| values.iter().all(|e| v != e))), + Operator::Contains(needle) => properties + .get(&self.key) + .is_some_and(|v| v.as_ref().is_some_and(|v| contains_match(v, needle))), + Operator::NotContains(needle) => properties + .get(&self.key) + .is_some_and(|v| v.as_ref().is_some_and(|v| !contains_match(v, needle))), Operator::Exists => properties.contains_key(&self.key), Operator::DoesNotExist => !properties.contains_key(&self.key), } } } +// List values are bracketed: "[a,b,c]". Scalars have no brackets. +fn contains_match(value: &str, needle: &str) -> bool { + if let Some(inner) = value.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + inner.split(',').any(|elem| elem == needle) + } else { + value.contains(needle) + } +} + +// Split on commas that are not inside parentheses (to preserve `in (a,b,c)`). +fn split_conditions(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut depth = 0; + + for (i, ch) in s.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 0 => { + parts.push(&s[start..i]); + start = i + 1; + } + _ => {} + } + } + parts.push(&s[start..]); + parts +} + +fn parse_condition(s: &str) -> Result { + // Try != before = to avoid matching the wrong operator + if let Some((key, val)) = s.split_once("!=") { + return Ok(Condition { + key: key.trim().to_string(), + operator: Operator::NotEquals(val.trim().to_string()), + }); + } + + if let Some((key, val)) = s.split_once('=') { + return Ok(Condition { + key: key.trim().to_string(), + operator: Operator::Equals(val.trim().to_string()), + }); + } + + // Keyword operators: "key in (...)", "key notin (...)", "key contains val", "key notcontains val" + let parts: Vec<&str> = s.splitn(3, ' ').collect(); + if parts.len() >= 2 { + let key = parts[0].trim(); + match parts[1].trim() { + "in" => { + let rest = parts.get(2).unwrap_or(&"").trim(); + let values = parse_value_list(rest)?; + return Ok(Condition { + key: key.to_string(), + operator: Operator::In(values), + }); + } + "notin" => { + let rest = parts.get(2).unwrap_or(&"").trim(); + let values = parse_value_list(rest)?; + return Ok(Condition { + key: key.to_string(), + operator: Operator::NotIn(values), + }); + } + "contains" => { + let val = parts.get(2).unwrap_or(&"").trim(); + if val.is_empty() { + anyhow::bail!("missing value for 'contains' in: {s}"); + } + return Ok(Condition { + key: key.to_string(), + operator: Operator::Contains(val.to_string()), + }); + } + "notcontains" => { + let val = parts.get(2).unwrap_or(&"").trim(); + if val.is_empty() { + anyhow::bail!("missing value for 'notcontains' in: {s}"); + } + return Ok(Condition { + key: key.to_string(), + operator: Operator::NotContains(val.to_string()), + }); + } + _ => {} + } + } + + // Existence: "!key" or "key" + let trimmed = s.trim(); + if let Some(key) = trimmed.strip_prefix('!') { + Ok(Condition { + key: key.to_string(), + operator: Operator::DoesNotExist, + }) + } else { + Ok(Condition { + key: trimmed.to_string(), + operator: Operator::Exists, + }) + } +} + +fn parse_value_list(s: &str) -> Result> { + let inner = s + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or_else(|| anyhow::anyhow!("expected parenthesized list like (a,b,c), got: {s}"))?; + Ok(inner.split(',').map(|v| v.trim().to_string()).collect()) +} + /// A category claim with an optional selector for discriminator-based dispatch. #[derive(Debug, Clone)] pub struct CategoryClaim { @@ -150,3 +294,157 @@ pub trait ConfigHandler { vec![] } } + +#[cfg(test)] +mod tests { + use super::*; + + fn props(pairs: &[(&str, &str)]) -> HashMap> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), Some(v.to_string()))) + .collect() + } + + #[test] + fn parse_equals() { + let s = Selector::parse("name=foo").unwrap(); + assert_eq!(s.conditions.len(), 1); + assert_eq!(s.conditions[0].key, "name"); + assert_eq!( + s.conditions[0].operator, + Operator::Equals("foo".to_string()) + ); + } + + #[test] + fn parse_not_equals() { + let s = Selector::parse("name!=foo").unwrap(); + assert_eq!( + s.conditions[0].operator, + Operator::NotEquals("foo".to_string()) + ); + } + + #[test] + fn parse_exists_and_not_exists() { + let s = Selector::parse("dependents,!labels.internal").unwrap(); + assert_eq!(s.conditions.len(), 2); + assert_eq!(s.conditions[0].operator, Operator::Exists); + assert_eq!(s.conditions[0].key, "dependents"); + assert_eq!(s.conditions[1].operator, Operator::DoesNotExist); + assert_eq!(s.conditions[1].key, "labels.internal"); + } + + #[test] + fn parse_in() { + let s = Selector::parse("labels.domain in (payments,inventory)").unwrap(); + assert_eq!( + s.conditions[0].operator, + Operator::In(vec!["payments".to_string(), "inventory".to_string()]) + ); + } + + #[test] + fn parse_notin() { + let s = Selector::parse("labels.env notin (dev,staging)").unwrap(); + assert_eq!( + s.conditions[0].operator, + Operator::NotIn(vec!["dev".to_string(), "staging".to_string()]) + ); + } + + #[test] + fn parse_contains() { + let s = Selector::parse("exports contains get-value").unwrap(); + assert_eq!( + s.conditions[0].operator, + Operator::Contains("get-value".to_string()) + ); + } + + #[test] + fn parse_notcontains() { + let s = Selector::parse("dependents notcontains logger").unwrap(); + assert_eq!( + s.conditions[0].operator, + Operator::NotContains("logger".to_string()) + ); + } + + #[test] + fn parse_multiple_conditions() { + let s = Selector::parse("name=foo,labels.domain=payments,!dependents").unwrap(); + assert_eq!(s.conditions.len(), 3); + } + + #[test] + fn parse_in_with_commas_preserved() { + let s = Selector::parse("labels.env in (prod,staging),name=api").unwrap(); + assert_eq!(s.conditions.len(), 2); + assert_eq!( + s.conditions[0].operator, + Operator::In(vec!["prod".to_string(), "staging".to_string()]) + ); + assert_eq!( + s.conditions[1].operator, + Operator::Equals("api".to_string()) + ); + } + + #[test] + fn parse_empty_selector_fails() { + assert!(Selector::parse("").is_err()); + } + + #[test] + fn parse_in_missing_parens_fails() { + assert!(Selector::parse("key in a,b").is_err()); + } + + #[test] + fn match_equals() { + let s = Selector::parse("name=foo").unwrap(); + assert!(s.matches(&props(&[("name", "foo")]))); + assert!(!s.matches(&props(&[("name", "bar")]))); + } + + #[test] + fn match_exists_and_not_exists() { + let s = Selector::parse("!dependents").unwrap(); + assert!(s.matches(&props(&[("name", "foo")]))); + assert!(!s.matches(&props(&[("name", "foo"), ("dependents", "[api]")]))); + } + + #[test] + fn match_contains_list_element() { + let s = Selector::parse("exports contains get-value").unwrap(); + assert!(s.matches(&props(&[("exports", "[get-value,run]")]))); + assert!(!s.matches(&props(&[("exports", "[run,calc]")]))); + } + + #[test] + fn match_contains_list_no_substring_match() { + let s = Selector::parse("dependents contains translator").unwrap(); + // Should NOT match: "logging-translator" is not the element "translator" + assert!(!s.matches(&props(&[("dependents", "[logging-translator]")]))); + // Should match: "translator" is an exact element + assert!(s.matches(&props(&[("dependents", "[translator,logger]")]))); + } + + #[test] + fn match_contains_scalar_substring() { + let s = Selector::parse("name contains foo").unwrap(); + assert!(s.matches(&props(&[("name", "foobar")]))); + assert!(s.matches(&props(&[("name", "bazfoo")]))); + assert!(!s.matches(&props(&[("name", "bar")]))); + } + + #[test] + fn match_in_set() { + let s = Selector::parse("labels.domain in (payments,inventory)").unwrap(); + assert!(s.matches(&props(&[("labels.domain", "payments")]))); + assert!(s.matches(&props(&[("labels.domain", "inventory")]))); + assert!(!s.matches(&props(&[("labels.domain", "shipping")]))); + } +} diff --git a/src/lib.rs b/src/lib.rs index dc42168..0b50ecc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,8 @@ pub use config::types::{ pub use runtime::{Runtime, RuntimeBuilder}; pub use service::Service; pub use types::{ - CapabilityDefinition, Component, ComponentDefinition, ComponentInvoker, ComponentState, - Function, FunctionParam, MessagePublisher, + CapabilityDefinition, Component, ComponentDefinition, ComponentInvoker, ComponentMetadata, + ComponentState, Function, FunctionParam, MessagePublisher, }; // exposed for testing, hidden from docs diff --git a/src/main.rs b/src/main.rs index 7a0bc0b..adeae14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -use composable_runtime::{ComponentGraph, FunctionParam, Runtime}; +use composable_runtime::{Component, ComponentGraph, FunctionParam, Runtime, Selector}; use rustyline::Editor; use rustyline::error::ReadlineError; use rustyline::history::DefaultHistory; @@ -38,6 +38,10 @@ enum Command { /// Component definition files (.toml) and standalone .wasm files #[arg(required = true)] definitions: Vec, + + /// Filter components by selector (e.g. labels.domain=payments, !dependents, name in (foo, bar)) + #[arg(long)] + selector: Option, }, /// Publish a message to a channel Publish { @@ -89,10 +93,14 @@ async fn main() -> Result<()> { println!("{graph:#?}"); } } - Command::Shell { definitions } => { + Command::Shell { + definitions, + selector, + } => { + let selector = selector.map(|s| Selector::parse(&s)).transpose()?; let runtime = Runtime::builder().from_paths(&definitions).build().await?; runtime.start()?; - run_shell(&runtime).await?; + run_shell(&runtime, selector.as_ref()).await?; runtime.shutdown().await; } Command::Invoke { @@ -174,13 +182,20 @@ async fn run_invoke(runtime: &Runtime, target_args: Vec) -> Result<()> { Ok(()) } -async fn run_shell(runtime: &Runtime) -> Result<()> { - let components = runtime.list_components(); - println!( - "Successfully built runtime with {} components.", - components.len() - ); - +async fn run_shell(runtime: &Runtime, selector: Option<&Selector>) -> Result<()> { + let components = runtime.list_components(selector); + + if selector.is_some() { + println!( + "Shell session with {} selected components.", + components.len() + ); + } else { + println!( + "Successfully built runtime with {} components.", + components.len() + ); + } println!("Starting interactive session. Type 'help' for commands."); let mut rl = Editor::<(), DefaultHistory>::new()?; loop { @@ -188,7 +203,7 @@ async fn run_shell(runtime: &Runtime) -> Result<()> { match readline { Ok(line) => { let _ = rl.add_history_entry(line.as_str()); - if handle_command(line, runtime).await.is_err() { + if handle_command(line, runtime, &components).await.is_err() { break; } } @@ -210,7 +225,11 @@ async fn run_shell(runtime: &Runtime) -> Result<()> { Ok(()) } -async fn handle_command(line: String, runtime: &Runtime) -> Result<(), ()> { +async fn handle_command( + line: String, + runtime: &Runtime, + components: &[&Component], +) -> Result<(), ()> { let parts = parse_quoted_args(&line); if let Some(command_str) = parts.first() { @@ -266,9 +285,9 @@ async fn handle_command(line: String, runtime: &Runtime) -> Result<(), ()> { match command { ShellCommand::List => { let mut targets = Vec::new(); - for component in runtime.list_components() { + for component in components { for func_name in component.functions.keys() { - targets.push(format!("{}.{}", component.name, func_name)); + targets.push(format!("{}.{}", component.metadata.name, func_name)); } } targets.sort(); @@ -278,7 +297,7 @@ async fn handle_command(line: String, runtime: &Runtime) -> Result<(), ()> { } ShellCommand::Describe { target } => { if let Some((component_name, func_name)) = target.split_once('.') { - if let Some(component) = runtime.get_component(component_name) { + if let Some(component) = find_component(components, component_name) { if let Some(function) = component.functions.get(func_name) { println!("Target: {target}"); if !function.docs().is_empty() { @@ -316,7 +335,7 @@ async fn handle_command(line: String, runtime: &Runtime) -> Result<(), ()> { } ShellCommand::Invoke { target, args } => { if let Some((component_name, func_name)) = target.split_once('.') { - if let Some(component) = runtime.get_component(component_name) { + if let Some(component) = find_component(components, component_name) { if let Some(function) = component.functions.get(func_name) { match parse_invoke_args(&args, function.params()) { Ok(final_args) => { @@ -406,6 +425,10 @@ fn parse_invoke_args( Ok(final_args) } +fn find_component<'a>(components: &[&'a Component], name: &str) -> Option<&'a Component> { + components.iter().find(|c| c.metadata.name == name).copied() +} + fn parse_quoted_args(line: &str) -> Vec { let mut parts = Vec::new(); let mut current = String::new(); diff --git a/src/messaging/activator.rs b/src/messaging/activator.rs index f9f1fdd..d356c9c 100644 --- a/src/messaging/activator.rs +++ b/src/messaging/activator.rs @@ -49,7 +49,7 @@ impl DefaultMapper { return Err(format!( "default mapping currently requires exactly 1 exported function, \ '{}' has {}", - component.name, + component.metadata.name, functions.len() )); } @@ -60,7 +60,7 @@ impl DefaultMapper { let function = component.functions.get(&function_key).ok_or_else(|| { format!( "function '{}' not found in '{}'", - function_key, component.name + function_key, component.metadata.name ) })?; let param_count = function.params().len(); @@ -137,12 +137,12 @@ impl Activator { .get_component(component_name) .ok_or_else(|| format!("component '{component_name}' not found"))?; - let mode = if Self::exports_handler_interface(&component) { + let mode = if Self::exports_handler_interface(component) { InvocationMode::Direct } else { let mapper = match mapper { Some(m) => m, - None => Box::new(DefaultMapper::from_component(&component, None)?), + None => Box::new(DefaultMapper::from_component(component, None)?), }; InvocationMode::Mapped { mapper } }; diff --git a/src/runtime/host.rs b/src/runtime/host.rs index 5463d59..1202ad1 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -19,12 +19,13 @@ use wasmtime_wasi_http::{HttpResult, WasiHttpCtx, WasiHttpView}; use wasmtime_wasi_io::IoView; use crate::composition::registry::{CapabilityRegistry, ComponentRegistry}; -use crate::types::{Component, ComponentInvoker, ComponentState, Function}; +use crate::types::{Component, ComponentInvoker, ComponentMetadata, ComponentState, Function}; // Component host: wasmtime engine + registries, provides instantiation + invocation. #[derive(Clone)] pub(crate) struct ComponentHost { invoker: Invoker, + components: HashMap, pub(crate) component_registry: ComponentRegistry, pub(crate) capability_registry: CapabilityRegistry, } @@ -35,8 +36,26 @@ impl ComponentHost { capability_registry: CapabilityRegistry, ) -> Result { let invoker = Invoker::new()?; + let components = component_registry + .get_components() + .map(|spec| { + let component = Component { + metadata: ComponentMetadata { + name: spec.name.clone(), + namespace: spec.namespace.clone(), + package: spec.package.clone(), + labels: spec.labels.clone(), + dependents: Some(spec.dependents.clone()), + exports: spec.exports.clone(), + }, + functions: spec.functions.clone(), + }; + (spec.name.clone(), component) + }) + .collect(); Ok(Self { invoker, + components, component_registry, capability_registry, }) @@ -92,13 +111,22 @@ impl ComponentHost { } impl ComponentInvoker for ComponentHost { - fn get_component(&self, name: &str) -> Option { - self.component_registry - .get_component(name) - .map(|spec| Component { - name: spec.name.clone(), - functions: spec.functions.clone(), - }) + fn get_component(&self, name: &str) -> Option<&Component> { + self.components.get(name) + } + + fn list_components( + &self, + selector: Option<&crate::config::types::Selector>, + ) -> Vec<&Component> { + match selector { + Some(selector) => self + .components + .values() + .filter(|c| selector.matches(&c.metadata.to_selectable())) + .collect(), + None => self.components.values().collect(), + } } fn invoke<'a>( diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 48e3bd2..a3c8720 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -31,20 +31,16 @@ impl Runtime { RuntimeBuilder::new() } - /// List all components - pub fn list_components(&self) -> Vec { - self.host - .component_registry - .get_components() - .map(|spec| Component { - name: spec.name.clone(), - functions: spec.functions.clone(), - }) - .collect() + /// List all components, optionally filtered by a selector + pub fn list_components( + &self, + selector: Option<&crate::config::types::Selector>, + ) -> Vec<&Component> { + self.host.list_components(selector) } /// Get a specific component by name - pub fn get_component(&self, name: &str) -> Option { + pub fn get_component(&self, name: &str) -> Option<&Component> { self.host.get_component(name) } diff --git a/src/types.rs b/src/types.rs index e461ee0..82606c3 100644 --- a/src/types.rs +++ b/src/types.rs @@ -29,6 +29,7 @@ pub struct ComponentDefinition { pub imports: Vec, pub interceptors: Vec, pub config: HashMap, + pub labels: HashMap, } /// State passed to Wasm components during execution. @@ -210,16 +211,65 @@ pub struct FunctionParam { pub json_schema: serde_json::Value, } +/// Metadata about a component, available for selector evaluation. +/// `dependents` is `None` before registry building (e.g. at scope evaluation time). +#[derive(Debug, Clone)] +pub struct ComponentMetadata { + pub name: String, + pub namespace: Option, + pub package: Option, + pub labels: HashMap, + pub dependents: Option>, + pub exports: Vec, +} + +impl ComponentMetadata { + /// Flatten metadata into a selectable map for selector evaluation. + /// Top-level fields become direct keys. Labels are prefixed with `labels.`. + /// `None` fields are omitted (enabling existence checks like `!dependents`). + pub fn to_selectable(&self) -> HashMap> { + let mut map = HashMap::new(); + map.insert("name".to_string(), Some(self.name.clone())); + if let Some(ns) = &self.namespace { + map.insert("namespace".to_string(), Some(ns.clone())); + } + if let Some(pkg) = &self.package { + map.insert("package".to_string(), Some(pkg.clone())); + } + if let Some(dependents) = &self.dependents + && !dependents.is_empty() + { + map.insert( + "dependents".to_string(), + Some(format!("[{}]", dependents.join(","))), + ); + } + if !self.exports.is_empty() { + map.insert( + "exports".to_string(), + Some(format!("[{}]", self.exports.join(","))), + ); + } + for (k, v) in &self.labels { + map.insert(format!("labels.{k}"), Some(v.clone())); + } + map + } +} + /// A named Wasm Component and its exported functions. #[derive(Debug, Clone)] pub struct Component { - pub name: String, + pub metadata: ComponentMetadata, pub functions: HashMap, } /// Invoke components by name. pub trait ComponentInvoker: Send + Sync { - fn get_component(&self, name: &str) -> Option; + fn get_component(&self, name: &str) -> Option<&Component>; + + fn list_components(&self, selector: Option<&crate::config::types::Selector>) + -> Vec<&Component>; fn invoke<'a>( &'a self, diff --git a/tests/host_capabilities.rs b/tests/host_capabilities.rs index 461c065..2b712ff 100644 --- a/tests/host_capabilities.rs +++ b/tests/host_capabilities.rs @@ -71,9 +71,9 @@ async fn test_host_capability_provides_interface() { let runtime = runtime.unwrap(); // Verify the component was registered - let components: Vec<_> = runtime.list_components(); + let components: Vec<_> = runtime.list_components(None); assert_eq!(components.len(), 1); - assert_eq!(components[0].name, "guest"); + assert_eq!(components[0].metadata.name, "guest"); } #[tokio::test] From 75feee73d94752cddf7beea86974e4944f12cd9a Mon Sep 17 00:00:00 2001 From: markfisher Date: Fri, 20 Mar 2026 20:02:45 -0400 Subject: [PATCH 2/3] validate no empty lists Signed-off-by: markfisher --- src/config/types.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/config/types.rs b/src/config/types.rs index ae55425..31ab953 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -47,7 +47,7 @@ pub struct Selector { impl Selector { /// Parse a selector string. /// - /// Comma-separated conditions, AND semantics. Supported expressions: + /// Comma-separated conditions with AND semantics. Supported expressions: /// - equality/inequality: `key=val`, `key!=val` /// - set membership: `key in (a,b,c)`, `key notin (a,b,c)` /// - substring or list element match: `key contains val`, `key notcontains val` @@ -210,6 +210,9 @@ fn parse_value_list(s: &str) -> Result> { .strip_prefix('(') .and_then(|s| s.strip_suffix(')')) .ok_or_else(|| anyhow::anyhow!("expected parenthesized list like (a,b,c), got: {s}"))?; + if inner.trim().is_empty() { + anyhow::bail!("empty value list in: {s}"); + } Ok(inner.split(',').map(|v| v.trim().to_string()).collect()) } @@ -402,6 +405,16 @@ mod tests { assert!(Selector::parse("key in a,b").is_err()); } + #[test] + fn parse_in_empty_list_fails() { + assert!(Selector::parse("key in ()").is_err()); + } + + #[test] + fn parse_notin_empty_list_fails() { + assert!(Selector::parse("key notin ()").is_err()); + } + #[test] fn match_equals() { let s = Selector::parse("name=foo").unwrap(); From d8327e3bdb479b2970cf8a9b61f24cdc015c67b6 Mon Sep 17 00:00:00 2001 From: markfisher Date: Fri, 20 Mar 2026 20:07:55 -0400 Subject: [PATCH 3/3] component metadata naming Signed-off-by: markfisher --- src/composition/registry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/composition/registry.rs b/src/composition/registry.rs index fb7e3d7..9bde5a0 100644 --- a/src/composition/registry.rs +++ b/src/composition/registry.rs @@ -527,7 +527,7 @@ async fn process_component( let mut all_capabilities = HashSet::new(); - let requester_metadata = ComponentMetadata { + let component_metadata = ComponentMetadata { name: definition.name.clone(), namespace: metadata.namespace.clone(), package: metadata.name.clone(), @@ -544,7 +544,7 @@ async fn process_component( let component_spec = component_registry.get_required_import( dependency_def, definition, - &requester_metadata, + &component_metadata, )?; if matches!(edge, Edge::Interceptor(_)) && is_advice_component(&exports) {