1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
15#[cfg_attr(feature = "schema", ts(rename_all = "snake_case"))]
16#[serde(rename_all = "snake_case")]
17pub enum OptionKind {
18 String,
19 Int,
20 Bool,
21 Float,
22 Duration,
23 Enum(Vec<String>),
24 List(Box<OptionKind>),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
33#[cfg_attr(feature = "schema", ts(rename_all = "snake_case"))]
34pub struct UriOption {
35 pub name: String,
36 pub description: String,
37 pub kind: OptionKind,
38 #[cfg_attr(feature = "schema", schemars(default))]
39 #[serde(default)]
40 pub required: bool,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub default_value: Option<String>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub aliases: Vec<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub deprecated: Option<String>,
47 #[cfg_attr(feature = "schema", schemars(default))]
48 #[serde(default)]
49 pub secret: bool,
50}
51
52impl UriOption {
53 pub fn new(name: &str, description: &str, kind: OptionKind) -> Self {
54 Self {
55 name: name.to_string(),
56 description: description.to_string(),
57 kind,
58 required: false,
59 default_value: None,
60 aliases: Vec::new(),
61 deprecated: None,
62 secret: false,
63 }
64 }
65
66 #[must_use]
67 pub fn required(mut self) -> Self {
68 self.required = true;
69 self
70 }
71
72 #[must_use]
73 pub fn with_default(mut self, value: &str) -> Self {
74 self.default_value = Some(value.to_string());
75 self
76 }
77
78 #[must_use]
79 pub fn with_alias(mut self, alias: &str) -> Self {
80 self.aliases.push(alias.to_string());
81 self
82 }
83
84 #[must_use]
85 pub fn deprecated(mut self, reason: &str) -> Self {
86 self.deprecated = Some(reason.to_string());
87 self
88 }
89
90 #[must_use]
91 pub fn secret(mut self) -> Self {
92 self.secret = true;
93 self
94 }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
103pub struct ComponentCapabilities {
104 pub supports_consumer: bool,
105 pub supports_producer: bool,
106 pub supports_polling_consumer: bool,
107 pub supports_streaming: bool,
108}
109
110impl ComponentCapabilities {
111 pub fn matches_query(&self, query: &CapabilityQuery) -> bool {
115 query
116 .supports_consumer
117 .is_none_or(|v| self.supports_consumer == v)
118 && query
119 .supports_producer
120 .is_none_or(|v| self.supports_producer == v)
121 && query
122 .supports_polling_consumer
123 .is_none_or(|v| self.supports_polling_consumer == v)
124 && query
125 .supports_streaming
126 .is_none_or(|v| self.supports_streaming == v)
127 }
128}
129
130#[derive(Debug, Clone, Default)]
138pub struct CapabilityQuery {
139 pub supports_consumer: Option<bool>,
140 pub supports_producer: Option<bool>,
141 pub supports_polling_consumer: Option<bool>,
142 pub supports_streaming: Option<bool>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
151pub struct ComponentMetadata {
152 pub scheme: String,
153 pub schema_version: String,
154 pub version: String,
155 pub description: String,
156 pub uri_syntax: String,
157 pub capabilities: ComponentCapabilities,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub uri_options: Vec<UriOption>,
160}
161
162impl ComponentMetadata {
163 pub const SCHEMA_VERSION: &'static str = "1";
165
166 pub fn minimal(scheme: &str) -> Self {
172 Self {
173 scheme: scheme.to_string(),
174 schema_version: Self::SCHEMA_VERSION.to_string(),
175 version: String::new(),
176 description: String::new(),
177 uri_syntax: String::new(),
178 capabilities: ComponentCapabilities::default(),
179 uri_options: Vec::new(),
180 }
181 }
182
183 pub fn validate_scheme(&self, component_scheme: &str) -> Result<(), String> {
187 if self.scheme == component_scheme {
188 Ok(())
189 } else {
190 Err(format!(
191 "Scheme mismatch: component metadata scheme is '{}' but component scheme is '{}'",
192 self.scheme, component_scheme
193 ))
194 }
195 }
196}
197
198pub trait ComponentMetadataCatalog: Send + Sync {
207 fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata>;
209
210 fn schemes(&self) -> Vec<String>;
212
213 fn all_metadata(&self) -> Vec<ComponentMetadata>;
215
216 fn query_capabilities(&self, query: &CapabilityQuery) -> Vec<ComponentMetadata> {
219 self.all_metadata()
220 .into_iter()
221 .filter(|m| m.capabilities.matches_query(query))
222 .collect()
223 }
224}
225
226#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn uri_option_builder_chain() {
236 let opt = UriOption::new("my_param", "A test parameter", OptionKind::String)
237 .required()
238 .with_default("default_val")
239 .with_alias("mp")
240 .with_alias("my-param")
241 .deprecated("Use new_param instead")
242 .secret();
243
244 assert_eq!(opt.name, "my_param");
245 assert_eq!(opt.description, "A test parameter");
246 assert_eq!(opt.kind, OptionKind::String);
247 assert!(opt.required);
248 assert_eq!(opt.default_value, Some("default_val".to_string()));
249 assert_eq!(opt.aliases, vec!["mp".to_string(), "my-param".to_string()]);
250 assert_eq!(opt.deprecated, Some("Use new_param instead".to_string()));
251 assert!(opt.secret);
252 }
253
254 #[test]
255 fn enum_kind_holds_variants() {
256 let kind = OptionKind::Enum(vec!["a".to_string(), "b".to_string()]);
257 match &kind {
258 OptionKind::Enum(variants) => {
259 assert_eq!(variants.len(), 2);
260 assert_eq!(variants[0], "a");
261 assert_eq!(variants[1], "b");
262 }
263 other => panic!("Expected Enum variant, got {other:?}"),
264 }
265 }
266
267 #[test]
268 fn component_capabilities_default_all_false() {
269 let caps = ComponentCapabilities::default();
270 assert!(!caps.supports_consumer);
271 assert!(!caps.supports_producer);
272 assert!(!caps.supports_polling_consumer);
273 assert!(!caps.supports_streaming);
274 }
275
276 #[test]
277 fn minimal_metadata_has_scheme_and_empty_fields() {
278 let meta = ComponentMetadata::minimal("timer");
279 assert_eq!(meta.scheme, "timer");
280 assert_eq!(meta.schema_version, "1");
281 assert!(meta.description.is_empty());
282 assert!(meta.uri_syntax.is_empty());
283 assert!(meta.uri_options.is_empty());
284 assert!(meta.version.is_empty());
285 }
286
287 #[test]
288 fn validate_scheme_mismatch_returns_err() {
289 let meta = ComponentMetadata::minimal("timer");
290 let result = meta.validate_scheme("log");
291 assert!(result.is_err());
292 let err_msg = result.unwrap_err();
293 assert!(err_msg.contains("timer"));
294 assert!(err_msg.contains("log"));
295 }
296
297 #[test]
298 fn capability_query_tri_state_matching() {
299 let caps = ComponentCapabilities {
300 supports_consumer: true,
301 ..Default::default()
302 };
303
304 assert!(caps.matches_query(&CapabilityQuery {
306 supports_consumer: Some(true),
307 ..Default::default()
308 }));
309
310 assert!(!caps.matches_query(&CapabilityQuery {
313 supports_producer: Some(true),
314 ..Default::default()
315 }));
316
317 assert!(caps.matches_query(&CapabilityQuery {
320 supports_producer: Some(false),
321 ..Default::default()
322 }));
323 }
324
325 struct MockCatalog {
330 entries: std::collections::HashMap<String, ComponentMetadata>,
331 }
332
333 impl ComponentMetadataCatalog for MockCatalog {
334 fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata> {
335 self.entries.get(scheme).cloned()
336 }
337
338 fn schemes(&self) -> Vec<String> {
339 self.entries.keys().cloned().collect()
340 }
341
342 fn all_metadata(&self) -> Vec<ComponentMetadata> {
343 self.entries.values().cloned().collect()
344 }
345 }
346
347 #[test]
348 fn catalog_trait_object_safety() {
349 let mut entries = std::collections::HashMap::new();
350 entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
351 let catalog = MockCatalog { entries };
352 let dyn_catalog: &dyn ComponentMetadataCatalog = &catalog;
353 assert_eq!(dyn_catalog.schemes(), vec!["timer".to_string()]);
354 }
355
356 #[test]
357 fn catalog_query_capabilities_default_impl() {
358 let consumer = ComponentMetadata {
359 scheme: "timer".to_string(),
360 capabilities: ComponentCapabilities {
361 supports_consumer: true,
362 ..Default::default()
363 },
364 ..ComponentMetadata::minimal("timer")
365 };
366 let producer = ComponentMetadata {
367 scheme: "log".to_string(),
368 capabilities: ComponentCapabilities {
369 supports_producer: true,
370 ..Default::default()
371 },
372 ..ComponentMetadata::minimal("log")
373 };
374 let mut entries = std::collections::HashMap::new();
375 entries.insert("timer".to_string(), consumer);
376 entries.insert("log".to_string(), producer);
377 let catalog = MockCatalog { entries };
378
379 let query_consumer = CapabilityQuery {
380 supports_consumer: Some(true),
381 ..Default::default()
382 };
383 let results = catalog.query_capabilities(&query_consumer);
384 assert_eq!(results.len(), 1);
385 assert_eq!(results[0].scheme, "timer");
386
387 let query_producer = CapabilityQuery {
388 supports_producer: Some(true),
389 ..Default::default()
390 };
391 let results = catalog.query_capabilities(&query_producer);
392 assert_eq!(results.len(), 1);
393 assert_eq!(results[0].scheme, "log");
394
395 let query_none = CapabilityQuery::default();
397 let results = catalog.query_capabilities(&query_none);
398 assert_eq!(results.len(), 2);
399 }
400
401 #[test]
402 fn catalog_schemes_and_all_metadata() {
403 let mut entries = std::collections::HashMap::new();
404 entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
405 entries.insert("log".to_string(), ComponentMetadata::minimal("log"));
406 let catalog = MockCatalog { entries };
407
408 let mut schemes = catalog.schemes();
409 schemes.sort();
410 assert_eq!(schemes, vec!["log".to_string(), "timer".to_string()]);
411
412 let all = catalog.all_metadata();
413 assert_eq!(all.len(), 2);
414 let schemes_from_meta: std::collections::BTreeSet<&str> =
415 all.iter().map(|m| m.scheme.as_str()).collect();
416 assert!(schemes_from_meta.contains("timer"));
417 assert!(schemes_from_meta.contains("log"));
418 }
419}