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