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"))]
35#[serde(rename_all = "snake_case")]
36#[non_exhaustive]
37pub enum UriOptionMatch {
38 #[serde(rename_all = "snake_case")]
39 Prefix {
40 #[cfg_attr(feature = "schema", schemars(default))]
41 separator: String,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
51#[cfg_attr(feature = "schema", ts(rename_all = "snake_case"))]
52pub struct UriOption {
53 pub name: String,
54 pub description: String,
55 pub kind: OptionKind,
56 #[cfg_attr(feature = "schema", schemars(default))]
57 #[serde(default)]
58 pub required: bool,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub default_value: Option<String>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub aliases: Vec<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub deprecated: Option<String>,
65 #[cfg_attr(feature = "schema", schemars(default))]
66 #[serde(default)]
67 pub secret: bool,
68 #[cfg_attr(feature = "schema", schemars(default))]
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub pattern: Option<UriOptionMatch>,
71}
72
73impl UriOption {
74 pub fn new(name: &str, description: &str, kind: OptionKind) -> Self {
75 Self {
76 name: name.to_string(),
77 description: description.to_string(),
78 kind,
79 required: false,
80 default_value: None,
81 aliases: Vec::new(),
82 deprecated: None,
83 secret: false,
84 pattern: None,
85 }
86 }
87
88 #[must_use]
89 pub fn required(mut self) -> Self {
90 self.required = true;
91 self
92 }
93
94 #[must_use]
95 pub fn with_default(mut self, value: &str) -> Self {
96 self.default_value = Some(value.to_string());
97 self
98 }
99
100 #[must_use]
101 pub fn with_alias(mut self, alias: &str) -> Self {
102 self.aliases.push(alias.to_string());
103 self
104 }
105
106 #[must_use]
107 pub fn deprecated(mut self, reason: &str) -> Self {
108 self.deprecated = Some(reason.to_string());
109 self
110 }
111
112 #[must_use]
113 pub fn secret(mut self) -> Self {
114 self.secret = true;
115 self
116 }
117
118 #[must_use]
119 pub fn pattern_prefix(mut self, separator: &str) -> Self {
120 self.pattern = Some(UriOptionMatch::Prefix {
121 separator: separator.to_string(),
122 });
123 self
124 }
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
133pub struct ComponentCapabilities {
134 pub supports_consumer: bool,
135 pub supports_producer: bool,
136 pub supports_polling_consumer: bool,
137 pub supports_streaming: bool,
138}
139
140impl ComponentCapabilities {
141 pub fn matches_query(&self, query: &CapabilityQuery) -> bool {
145 query
146 .supports_consumer
147 .is_none_or(|v| self.supports_consumer == v)
148 && query
149 .supports_producer
150 .is_none_or(|v| self.supports_producer == v)
151 && query
152 .supports_polling_consumer
153 .is_none_or(|v| self.supports_polling_consumer == v)
154 && query
155 .supports_streaming
156 .is_none_or(|v| self.supports_streaming == v)
157 }
158}
159
160#[derive(Debug, Clone, Default)]
168pub struct CapabilityQuery {
169 pub supports_consumer: Option<bool>,
170 pub supports_producer: Option<bool>,
171 pub supports_polling_consumer: Option<bool>,
172 pub supports_streaming: Option<bool>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
181pub struct ComponentMetadata {
182 pub scheme: String,
183 pub schema_version: String,
184 pub version: String,
185 pub description: String,
186 pub uri_syntax: String,
187 pub capabilities: ComponentCapabilities,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub uri_options: Vec<UriOption>,
190}
191
192impl ComponentMetadata {
193 pub const SCHEMA_VERSION: &'static str = "1";
195
196 pub fn minimal(scheme: &str) -> Self {
202 Self {
203 scheme: scheme.to_string(),
204 schema_version: Self::SCHEMA_VERSION.to_string(),
205 version: String::new(),
206 description: String::new(),
207 uri_syntax: String::new(),
208 capabilities: ComponentCapabilities::default(),
209 uri_options: Vec::new(),
210 }
211 }
212
213 #[must_use]
215 pub fn with_description(mut self, description: &str) -> Self {
216 self.description = description.to_string();
217 self
218 }
219
220 #[must_use]
222 pub fn with_capabilities(mut self, caps: ComponentCapabilities) -> Self {
223 self.capabilities = caps;
224 self
225 }
226
227 #[must_use]
229 pub fn with_uri_options(mut self, opts: Vec<UriOption>) -> Self {
230 self.uri_options.extend(opts);
231 self
232 }
233
234 pub fn validate_scheme(&self, component_scheme: &str) -> Result<(), String> {
238 if self.scheme == component_scheme {
239 Ok(())
240 } else {
241 Err(format!(
242 "Scheme mismatch: component metadata scheme is '{}' but component scheme is '{}'",
243 self.scheme, component_scheme
244 ))
245 }
246 }
247}
248
249pub trait ComponentMetadataCatalog: Send + Sync {
258 fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata>;
260
261 fn schemes(&self) -> Vec<String>;
263
264 fn all_metadata(&self) -> Vec<ComponentMetadata>;
266
267 fn query_capabilities(&self, query: &CapabilityQuery) -> Vec<ComponentMetadata> {
270 self.all_metadata()
271 .into_iter()
272 .filter(|m| m.capabilities.matches_query(query))
273 .collect()
274 }
275}
276
277#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
290 fn pattern_prefix_sets_prefix_variant() {
291 let opt = UriOption::new("param", "desc", OptionKind::String).pattern_prefix("param.");
292
293 assert_eq!(
294 opt.pattern,
295 Some(UriOptionMatch::Prefix {
296 separator: "param.".to_string()
297 })
298 );
299 assert_eq!(opt.name, "param");
301 assert_eq!(opt.description, "desc");
302 assert_eq!(opt.kind, OptionKind::String);
303 assert!(!opt.secret);
304 assert!(!opt.required);
305 assert_eq!(opt.default_value, None);
306 assert!(opt.aliases.is_empty());
307 assert_eq!(opt.deprecated, None);
308 }
309
310 #[test]
311 fn pattern_defaults_to_none() {
312 let opt = UriOption::new("foo", "desc", OptionKind::String);
313 assert_eq!(opt.pattern, None);
314 }
315
316 #[test]
317 fn serialize_pattern_none_omits_field() {
318 let opt = UriOption::new("foo", "desc", OptionKind::String);
319 let fixture = serde_json::to_string(&opt).unwrap();
321 assert!(
323 !fixture.contains("\"pattern\""),
324 "serialized JSON should not contain \"pattern\" when pattern is None, got: {fixture}"
325 );
326 let roundtripped: UriOption = serde_json::from_str(&fixture).unwrap();
328 assert_eq!(opt, roundtripped);
329 }
330
331 #[test]
332 fn serialize_pattern_some_emits_externally_tagged_snake_case() {
333 let opt = UriOption::new("param", "desc", OptionKind::String).pattern_prefix("param.");
334 let json = serde_json::to_string(&opt).unwrap();
335 assert!(
336 json.contains("\"pattern\":{\"prefix\":{\"separator\":\"param.\"}}"),
337 "expected externally-tagged snake_case serialization, got: {json}"
338 );
339 }
340
341 #[test]
342 fn deserialize_pattern_some_roundtrips() {
343 let opt = UriOption::new("param", "desc", OptionKind::String).pattern_prefix("param.");
344 let json = serde_json::to_string(&opt).unwrap();
345 let deser: UriOption = serde_json::from_str(&json).unwrap();
346 assert_eq!(
347 deser.pattern,
348 Some(UriOptionMatch::Prefix {
349 separator: "param.".to_string()
350 })
351 );
352 }
353
354 #[test]
359 fn uri_option_builder_chain() {
360 let opt = UriOption::new("my_param", "A test parameter", OptionKind::String)
361 .required()
362 .with_default("default_val")
363 .with_alias("mp")
364 .with_alias("my-param")
365 .deprecated("Use new_param instead")
366 .secret();
367
368 assert_eq!(opt.name, "my_param");
369 assert_eq!(opt.description, "A test parameter");
370 assert_eq!(opt.kind, OptionKind::String);
371 assert!(opt.required);
372 assert_eq!(opt.default_value, Some("default_val".to_string()));
373 assert_eq!(opt.aliases, vec!["mp".to_string(), "my-param".to_string()]);
374 assert_eq!(opt.deprecated, Some("Use new_param instead".to_string()));
375 assert!(opt.secret);
376 }
377
378 #[test]
379 fn enum_kind_holds_variants() {
380 let kind = OptionKind::Enum(vec!["a".to_string(), "b".to_string()]);
381 match &kind {
382 OptionKind::Enum(variants) => {
383 assert_eq!(variants.len(), 2);
384 assert_eq!(variants[0], "a");
385 assert_eq!(variants[1], "b");
386 }
387 other => panic!("Expected Enum variant, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn component_capabilities_default_all_false() {
393 let caps = ComponentCapabilities::default();
394 assert!(!caps.supports_consumer);
395 assert!(!caps.supports_producer);
396 assert!(!caps.supports_polling_consumer);
397 assert!(!caps.supports_streaming);
398 }
399
400 #[test]
401 fn minimal_metadata_has_scheme_and_empty_fields() {
402 let meta = ComponentMetadata::minimal("timer");
403 assert_eq!(meta.scheme, "timer");
404 assert_eq!(meta.schema_version, "1");
405 assert!(meta.description.is_empty());
406 assert!(meta.uri_syntax.is_empty());
407 assert!(meta.uri_options.is_empty());
408 assert!(meta.version.is_empty());
409 }
410
411 #[test]
412 fn with_description_sets() {
413 let meta = ComponentMetadata::minimal("sql").with_description("test");
414 assert_eq!(meta.description, "test");
415 }
416
417 #[test]
418 fn with_capabilities_sets_flags() {
419 let meta = ComponentMetadata::minimal("x").with_capabilities(ComponentCapabilities {
420 supports_producer: true,
421 ..Default::default()
422 });
423 assert!(meta.capabilities.supports_producer);
424 assert!(!meta.capabilities.supports_consumer);
425 }
426
427 #[test]
428 fn with_uri_options_appends() {
429 let meta = ComponentMetadata::minimal("x").with_uri_options(vec![UriOption::new(
430 "p",
431 "d",
432 OptionKind::String,
433 )]);
434 assert_eq!(meta.uri_options.len(), 1);
435 assert_eq!(meta.uri_options[0].name, "p");
436 }
437
438 #[test]
439 fn validate_scheme_mismatch_returns_err() {
440 let meta = ComponentMetadata::minimal("timer");
441 let result = meta.validate_scheme("log");
442 assert!(result.is_err());
443 let err_msg = result.unwrap_err();
444 assert!(err_msg.contains("timer"));
445 assert!(err_msg.contains("log"));
446 }
447
448 #[test]
449 fn capability_query_tri_state_matching() {
450 let caps = ComponentCapabilities {
451 supports_consumer: true,
452 ..Default::default()
453 };
454
455 assert!(caps.matches_query(&CapabilityQuery {
457 supports_consumer: Some(true),
458 ..Default::default()
459 }));
460
461 assert!(!caps.matches_query(&CapabilityQuery {
464 supports_producer: Some(true),
465 ..Default::default()
466 }));
467
468 assert!(caps.matches_query(&CapabilityQuery {
471 supports_producer: Some(false),
472 ..Default::default()
473 }));
474 }
475
476 struct MockCatalog {
481 entries: std::collections::HashMap<String, ComponentMetadata>,
482 }
483
484 impl ComponentMetadataCatalog for MockCatalog {
485 fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata> {
486 self.entries.get(scheme).cloned()
487 }
488
489 fn schemes(&self) -> Vec<String> {
490 self.entries.keys().cloned().collect()
491 }
492
493 fn all_metadata(&self) -> Vec<ComponentMetadata> {
494 self.entries.values().cloned().collect()
495 }
496 }
497
498 #[test]
499 fn catalog_trait_object_safety() {
500 let mut entries = std::collections::HashMap::new();
501 entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
502 let catalog = MockCatalog { entries };
503 let dyn_catalog: &dyn ComponentMetadataCatalog = &catalog;
504 assert_eq!(dyn_catalog.schemes(), vec!["timer".to_string()]);
505 }
506
507 #[test]
508 fn catalog_query_capabilities_default_impl() {
509 let consumer = ComponentMetadata {
510 scheme: "timer".to_string(),
511 capabilities: ComponentCapabilities {
512 supports_consumer: true,
513 ..Default::default()
514 },
515 ..ComponentMetadata::minimal("timer")
516 };
517 let producer = ComponentMetadata {
518 scheme: "log".to_string(),
519 capabilities: ComponentCapabilities {
520 supports_producer: true,
521 ..Default::default()
522 },
523 ..ComponentMetadata::minimal("log")
524 };
525 let mut entries = std::collections::HashMap::new();
526 entries.insert("timer".to_string(), consumer);
527 entries.insert("log".to_string(), producer);
528 let catalog = MockCatalog { entries };
529
530 let query_consumer = CapabilityQuery {
531 supports_consumer: Some(true),
532 ..Default::default()
533 };
534 let results = catalog.query_capabilities(&query_consumer);
535 assert_eq!(results.len(), 1);
536 assert_eq!(results[0].scheme, "timer");
537
538 let query_producer = CapabilityQuery {
539 supports_producer: Some(true),
540 ..Default::default()
541 };
542 let results = catalog.query_capabilities(&query_producer);
543 assert_eq!(results.len(), 1);
544 assert_eq!(results[0].scheme, "log");
545
546 let query_none = CapabilityQuery::default();
548 let results = catalog.query_capabilities(&query_none);
549 assert_eq!(results.len(), 2);
550 }
551
552 #[test]
553 fn catalog_schemes_and_all_metadata() {
554 let mut entries = std::collections::HashMap::new();
555 entries.insert("timer".to_string(), ComponentMetadata::minimal("timer"));
556 entries.insert("log".to_string(), ComponentMetadata::minimal("log"));
557 let catalog = MockCatalog { entries };
558
559 let mut schemes = catalog.schemes();
560 schemes.sort();
561 assert_eq!(schemes, vec!["log".to_string(), "timer".to_string()]);
562
563 let all = catalog.all_metadata();
564 assert_eq!(all.len(), 2);
565 let schemes_from_meta: std::collections::BTreeSet<&str> =
566 all.iter().map(|m| m.scheme.as_str()).collect();
567 assert!(schemes_from_meta.contains("timer"));
568 assert!(schemes_from_meta.contains("log"));
569 }
570}