1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use crate::Platform;
7use crate::path_safety::normalize_under_root;
8use anyhow::{Context, Result, anyhow, bail};
9use greentic_pack::messaging::{
10 MessagingAdapterCapabilities, MessagingAdapterKind, MessagingSection,
11};
12use greentic_pack::reader::{SigningPolicy, open_pack};
13use greentic_types::pack_manifest::{ExtensionInline, PackManifest as GpackManifest};
14use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderExtensionInline};
15
16#[derive(Debug, serde::Deserialize)]
17struct PackSpec {
18 id: String,
19 version: String,
20 #[serde(default)]
21 messaging: Option<MessagingSection>,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
25pub struct AdapterDescriptor {
26 pub pack_id: String,
27 pub pack_version: String,
28 pub name: String,
29 pub kind: MessagingAdapterKind,
30 pub component: String,
31 pub default_flow: Option<String>,
32 pub custom_flow: Option<String>,
33 pub capabilities: Option<MessagingAdapterCapabilities>,
34 pub source: Option<PathBuf>,
35}
36
37impl AdapterDescriptor {
38 pub fn allows_ingress(&self) -> bool {
40 matches!(
41 self.kind,
42 MessagingAdapterKind::Ingress | MessagingAdapterKind::IngressEgress
43 )
44 }
45
46 pub fn allows_egress(&self) -> bool {
48 matches!(
49 self.kind,
50 MessagingAdapterKind::Egress | MessagingAdapterKind::IngressEgress
51 )
52 }
53
54 pub fn flow_path(&self) -> Option<&str> {
56 self.custom_flow.as_deref().or(self.default_flow.as_deref())
57 }
58}
59
60#[derive(Default, Clone, Debug)]
61pub struct AdapterRegistry {
62 adapters: HashMap<String, AdapterDescriptor>,
63}
64
65#[derive(Clone, Debug)]
66pub struct AdapterPackFailure {
67 pub path: PathBuf,
68 pub error: String,
69}
70
71impl AdapterRegistry {
72 pub fn load_from_paths(root: &Path, paths: &[PathBuf]) -> Result<Self> {
73 load_adapters_from_pack_files(root, paths)
74 }
75
76 pub fn register(&mut self, adapter: AdapterDescriptor) -> Result<()> {
77 if self.adapters.contains_key(&adapter.name) {
78 bail!("duplicate adapter registration for {}", adapter.name);
79 }
80 self.adapters.insert(adapter.name.clone(), adapter);
81 Ok(())
82 }
83
84 pub fn get(&self, name: &str) -> Option<&AdapterDescriptor> {
85 self.adapters.get(name)
86 }
87
88 pub fn all(&self) -> Vec<AdapterDescriptor> {
89 self.adapters.values().cloned().collect()
90 }
91
92 pub fn by_kind(&self, kind: MessagingAdapterKind) -> Vec<AdapterDescriptor> {
93 self.adapters
94 .values()
95 .filter(|a| a.kind == kind)
96 .cloned()
97 .collect()
98 }
99
100 pub fn names(&self) -> Vec<String> {
101 self.adapters.keys().cloned().collect()
102 }
103
104 pub fn is_empty(&self) -> bool {
105 self.adapters.is_empty()
106 }
107}
108
109pub fn load_adapters_from_pack_files(root: &Path, paths: &[PathBuf]) -> Result<AdapterRegistry> {
110 let (registry, failures) = load_adapters_from_pack_files_with_failures(root, paths)?;
111 for failure in failures {
112 tracing::warn!(
113 pack = %failure.path.display(),
114 error = %failure.error,
115 "failed to load adapter pack"
116 );
117 }
118 Ok(registry)
119}
120
121pub fn load_adapters_from_pack_files_with_failures(
122 root: &Path,
123 paths: &[PathBuf],
124) -> Result<(AdapterRegistry, Vec<AdapterPackFailure>)> {
125 let root = root
126 .canonicalize()
127 .with_context(|| format!("failed to canonicalize packs root {}", root.display()))?;
128 let mut registry = AdapterRegistry::default();
129 let mut failures = Vec::new();
130 for path in paths {
131 let adapters = match adapters_from_pack_file(&root, path) {
132 Ok(adapters) => adapters,
133 Err(err) => {
134 failures.push(AdapterPackFailure {
135 path: path.to_path_buf(),
136 error: format!("{err:#}"),
137 });
138 continue;
139 }
140 };
141
142 let mut seen = HashSet::new();
143 let mut duplicate = None;
144 for adapter in &adapters {
145 if !seen.insert(adapter.name.clone()) {
146 duplicate = Some(format!("duplicate adapter name in pack: {}", adapter.name));
147 break;
148 }
149 if registry.adapters.contains_key(&adapter.name) {
150 duplicate = Some(format!(
151 "duplicate adapter registration for {}",
152 adapter.name
153 ));
154 break;
155 }
156 }
157 if let Some(message) = duplicate {
158 failures.push(AdapterPackFailure {
159 path: path.to_path_buf(),
160 error: message,
161 });
162 continue;
163 }
164
165 for adapter in adapters {
166 if let Err(err) = registry.register(adapter) {
167 failures.push(AdapterPackFailure {
168 path: path.to_path_buf(),
169 error: format!("{err:#}"),
170 });
171 break;
172 }
173 }
174 }
175 Ok((registry, failures))
176}
177
178pub fn adapters_from_pack_file(root: &Path, path: &Path) -> Result<Vec<AdapterDescriptor>> {
179 let safe_path = resolve_pack_path(root, path)?;
180 let ext = safe_path
181 .extension()
182 .and_then(|s| s.to_str())
183 .map(|s| s.to_ascii_lowercase());
184 match ext.as_deref() {
185 Some("gtpack") => adapters_from_gtpack(&safe_path),
186 _ => adapters_from_pack_yaml(&safe_path),
187 }
188}
189
190fn resolve_pack_path(root: &Path, path: &Path) -> Result<PathBuf> {
191 if path.is_absolute() {
192 let canonical_path = path
193 .canonicalize()
194 .with_context(|| format!("failed to canonicalize {}", path.display()))?;
195 Ok(canonical_path)
196 } else {
197 normalize_under_root(root, path)
198 }
199}
200
201fn adapters_from_pack_yaml(path: &Path) -> Result<Vec<AdapterDescriptor>> {
202 let raw = fs::read_to_string(path)
203 .with_context(|| format!("failed to read pack file {}", path.display()))?;
204 let spec: PackSpec = serde_yaml_bw::from_str(&raw)
205 .with_context(|| format!("{} is not a valid pack spec", path.display()))?;
206 validate_pack_spec(&spec)?;
207 extract_from_sources(
208 &spec.id,
209 &spec.version,
210 None,
211 spec.messaging.as_ref(),
212 Some(path),
213 )
214}
215
216fn adapters_from_gtpack(path: &Path) -> Result<Vec<AdapterDescriptor>> {
217 let pack = open_pack(path, SigningPolicy::DevOk)
218 .map_err(|err| anyhow!(err.message))
219 .with_context(|| format!("failed to open {}", path.display()))?;
220 let raw_manifest = zip_manifest_bytes(path);
226 let gpack_manifest: Option<GpackManifest> = raw_manifest
227 .as_ref()
228 .and_then(|bytes| greentic_types::decode_pack_manifest(bytes).ok());
229
230 let pack_id = gpack_manifest
231 .as_ref()
232 .map(|m| m.pack_id.to_string())
233 .unwrap_or_else(|| pack.manifest.meta.pack_id.clone());
234 let pack_version = gpack_manifest
235 .as_ref()
236 .map(|m| m.version.to_string())
237 .unwrap_or_else(|| pack.manifest.meta.version.to_string());
238
239 extract_from_sources(
240 &pack_id,
241 &pack_version,
242 gpack_manifest.as_ref().and_then(provider_inline),
243 pack.manifest.meta.messaging.as_ref(),
244 Some(path),
245 )
246}
247
248fn zip_manifest_bytes(path: &Path) -> Option<Vec<u8>> {
249 let file = std::fs::File::open(path).ok()?;
250 let mut archive = zip::ZipArchive::new(file).ok()?;
251 let mut buf = Vec::new();
252 archive
253 .by_name("manifest.cbor")
254 .ok()?
255 .read_to_end(&mut buf)
256 .ok()?;
257 Some(buf)
258}
259
260fn validate_pack_spec(spec: &PackSpec) -> Result<()> {
261 if spec.id.trim().is_empty() {
262 bail!("pack id must not be empty");
263 }
264 if spec.version.trim().is_empty() {
265 bail!("pack version must not be empty");
266 }
267 if let Some(messaging) = &spec.messaging {
268 messaging.validate()?;
269 }
270 Ok(())
271}
272
273fn extract_from_sources(
274 pack_id: &str,
275 pack_version: &str,
276 provider_inline: Option<ProviderExtensionInline>,
277 messaging: Option<&MessagingSection>,
278 source: Option<&Path>,
279) -> Result<Vec<AdapterDescriptor>> {
280 if let Some(provider) = provider_inline {
281 let adapters = extract_from_provider_extension(pack_id, pack_version, provider, source)?;
282 if !adapters.is_empty() {
283 return Ok(adapters);
284 }
285 }
286
287 let messaging_adapters = extract_from_pack_messaging(pack_id, pack_version, messaging, source)?;
288 if !messaging_adapters.is_empty() {
289 tracing::warn!(
290 pack_id = %pack_id,
291 "messaging.adapters used; prefer provider extension {}",
292 PROVIDER_EXTENSION_ID
293 );
294 return Ok(messaging_adapters);
295 }
296
297 tracing::warn!(
298 pack_id = %pack_id,
299 "no adapters found; add provider extension {} or messaging.adapters",
300 PROVIDER_EXTENSION_ID
301 );
302 Ok(Vec::new())
303}
304
305fn extract_from_provider_extension(
306 pack_id: &str,
307 pack_version: &str,
308 inline: ProviderExtensionInline,
309 source: Option<&Path>,
310) -> Result<Vec<AdapterDescriptor>> {
311 inline.validate_basic().map_err(|err| anyhow!(err))?;
312 let mut seen = HashSet::new();
313 let mut out = Vec::new();
314 for provider in inline.providers {
315 if !seen.insert(provider.provider_type.clone()) {
316 bail!(
317 "duplicate provider_type in extension: {}",
318 provider.provider_type
319 );
320 }
321 out.push(AdapterDescriptor {
322 pack_id: pack_id.to_string(),
323 pack_version: pack_version.to_string(),
324 name: provider.provider_type.clone(),
325 kind: MessagingAdapterKind::IngressEgress,
326 component: provider.runtime.component_ref.clone(),
327 default_flow: None,
328 custom_flow: None,
329 capabilities: provider_capabilities(&provider.capabilities),
330 source: source.map(Path::to_path_buf),
331 });
332 }
333 Ok(out)
334}
335
336fn provider_capabilities(caps: &[String]) -> Option<MessagingAdapterCapabilities> {
337 if caps.is_empty() {
338 return None;
339 }
340 Some(MessagingAdapterCapabilities {
341 direction: Vec::new(),
342 features: caps
343 .iter()
344 .filter_map(|c| map_provider_feature(c.as_str()))
345 .collect(),
346 })
347}
348
349fn map_provider_feature(feature: &str) -> Option<String> {
350 Some(feature.to_string())
352}
353
354fn extract_from_pack_messaging(
355 pack_id: &str,
356 pack_version: &str,
357 messaging: Option<&MessagingSection>,
358 source: Option<&Path>,
359) -> Result<Vec<AdapterDescriptor>> {
360 let mut out = Vec::new();
361 let messaging = match messaging {
362 Some(section) => section,
363 None => return Ok(out),
364 };
365 let adapters = match &messaging.adapters {
366 Some(list) => list,
367 None => return Ok(out),
368 };
369 let mut seen = std::collections::BTreeSet::new();
371 for adapter in adapters {
372 if !seen.insert(&adapter.name) {
373 bail!("duplicate messaging adapter name: {}", adapter.name);
374 }
375 out.push(AdapterDescriptor {
376 pack_id: pack_id.to_string(),
377 pack_version: pack_version.to_string(),
378 name: adapter.name.clone(),
379 kind: adapter.kind.clone(),
380 component: adapter.component.clone(),
381 default_flow: adapter.default_flow.clone(),
382 custom_flow: adapter.custom_flow.clone(),
383 capabilities: adapter.capabilities.clone(),
384 source: source.map(Path::to_path_buf),
385 });
386 }
387 Ok(out)
388}
389
390fn provider_inline(manifest: &GpackManifest) -> Option<ProviderExtensionInline> {
391 manifest.provider_extension_inline().cloned().or_else(|| {
392 manifest
393 .extensions
394 .as_ref()
395 .and_then(|exts| exts.get(PROVIDER_EXTENSION_ID))
396 .and_then(|ext| ext.inline.as_ref())
397 .and_then(|inline| match inline {
398 ExtensionInline::Provider(p) => Some(p.clone()),
399 _ => None,
400 })
401 })
402}
403
404pub fn infer_platform_from_adapter_name(name: &str) -> Option<Platform> {
406 let lowered = name.to_ascii_lowercase();
407 if lowered.starts_with("slack") {
408 Some(Platform::Slack)
409 } else if lowered.starts_with("teams") {
410 Some(Platform::Teams)
411 } else if lowered.starts_with("webex") {
412 Some(Platform::Webex)
413 } else if lowered.starts_with("webchat") {
414 Some(Platform::WebChat)
415 } else if lowered.starts_with("whatsapp") {
416 Some(Platform::WhatsApp)
417 } else if lowered.starts_with("telegram") {
418 Some(Platform::Telegram)
419 } else {
420 None
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use greentic_pack::messaging::MessagingAdapter;
428 use greentic_types::provider::{ProviderDecl, ProviderExtensionInline, ProviderRuntimeRef};
429 use std::io::Write;
430 use tempfile::TempDir;
431
432 #[test]
433 fn loads_slack_pack() {
434 let packs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packs");
435 let base = packs_root
436 .join("messaging/slack.yaml")
437 .canonicalize()
438 .expect("canonicalize pack path");
439 let registry =
440 load_adapters_from_pack_files(packs_root.as_path(), std::slice::from_ref(&base))
441 .unwrap();
442 let adapter = registry.get("slack-main").expect("adapter registered");
443 assert_eq!(adapter.pack_id, "greentic-messaging-slack");
444 assert_eq!(adapter.kind, MessagingAdapterKind::IngressEgress);
445 assert_eq!(adapter.component, "slack-adapter@1.0.0");
446 assert_eq!(
447 adapter.default_flow.as_deref(),
448 Some("flows/messaging/slack/default.ygtc")
449 );
450 assert_eq!(adapter.source.as_ref(), Some(&base));
451 }
452
453 fn write_provider_gtpack(path: &Path, provider_type: &str, component_ref: &str) {
454 use greentic_types::PackId;
455 use greentic_types::pack_manifest::{
456 ExtensionInline, ExtensionRef, PackKind, PackManifest, PackSignatures,
457 };
458
459 let mut extensions = std::collections::BTreeMap::new();
460 extensions.insert(
461 greentic_types::provider::PROVIDER_EXTENSION_ID.to_string(),
462 ExtensionRef {
463 kind: greentic_types::provider::PROVIDER_EXTENSION_ID.to_string(),
464 version: "1.0.0".to_string(),
465 digest: None,
466 location: None,
467 inline: Some(ExtensionInline::Provider(ProviderExtensionInline {
468 providers: vec![ProviderDecl {
469 provider_type: provider_type.to_string(),
470 capabilities: vec![],
471 ops: vec![],
472 config_schema_ref: "schemas/config.json".into(),
473 state_schema_ref: None,
474 runtime: ProviderRuntimeRef {
475 component_ref: component_ref.to_string(),
476 export: "run".into(),
477 world: "greentic:provider/schema-core@1.0.0".into(),
478 },
479 docs_ref: None,
480 }],
481 additional_fields: Default::default(),
482 })),
483 },
484 );
485
486 let manifest = PackManifest {
487 schema_version: "pack-v1".into(),
488 pack_id: PackId::new("adapter-registry.providers").unwrap(),
489 version: semver::Version::new(0, 0, 1),
490 kind: PackKind::Provider,
491 publisher: "test".into(),
492 components: Vec::new(),
493 flows: Vec::new(),
494 dependencies: Vec::new(),
495 capabilities: Vec::new(),
496 secret_requirements: Vec::new(),
497 signatures: PackSignatures::default(),
498 bootstrap: None,
499 extensions: Some(extensions),
500 };
501 let manifest_bytes = greentic_types::encode_pack_manifest(&manifest).unwrap();
502 assert!(
504 greentic_types::decode_pack_manifest(&manifest_bytes)
505 .unwrap()
506 .provider_extension_inline()
507 .is_some()
508 );
509
510 let file = std::fs::File::create(path).expect("pack file");
511 let mut zip = zip::ZipWriter::new(file);
512 let opts = zip::write::SimpleFileOptions::default()
513 .compression_method(zip::CompressionMethod::Stored);
514 zip.start_file("manifest.cbor", opts)
515 .expect("start manifest");
516 zip.write_all(&manifest_bytes).expect("write manifest");
517 zip.finish().expect("finish zip");
518 }
519
520 #[test]
521 fn loads_provider_extension_gtpack() {
522 let temp = TempDir::new().expect("temp dir");
523 let gtpack_path = temp.path().join("provider.gtpack");
524 write_provider_gtpack(>pack_path, "messaging.slack.bot", "slack-adapter@1.0.0");
525
526 let registry =
527 load_adapters_from_pack_files(temp.path(), std::slice::from_ref(>pack_path))
528 .expect("load adapters");
529 let adapter = registry
530 .get("messaging.slack.bot")
531 .expect("adapter registered from provider extension");
532 assert_eq!(adapter.component, "slack-adapter@1.0.0");
533 }
534
535 #[test]
536 fn by_kind_filters() {
537 let packs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packs");
538 let paths = vec![
539 packs_root
540 .join("messaging/slack.yaml")
541 .canonicalize()
542 .expect("canonicalize pack path"),
543 packs_root
544 .join("messaging/telegram.yaml")
545 .canonicalize()
546 .expect("canonicalize pack path"),
547 ];
548 let registry = load_adapters_from_pack_files(packs_root.as_path(), &paths).unwrap();
549 let ingress = registry.by_kind(MessagingAdapterKind::Ingress);
550 assert!(ingress.iter().any(|a| a.name == "telegram-ingress"));
551 let egress = registry.by_kind(MessagingAdapterKind::Egress);
552 assert!(egress.iter().any(|a| a.name == "telegram-egress"));
553 let both = registry.by_kind(MessagingAdapterKind::IngressEgress);
554 assert!(both.iter().any(|a| a.name == "slack-main"));
555 }
556
557 #[test]
558 fn loads_gtpack_archive() {
559 let temp = TempDir::new().expect("temp dir");
560 let gtpack_path = temp.path().join("demo.gtpack");
561
562 let flow_yaml = r#"id: demo-flow
563type: messaging
564in: start
565nodes: {}
566"#;
567 let flow_bundle = greentic_flow::flow_bundle::FlowBundle {
568 id: "demo-flow".to_string(),
569 kind: "messaging".to_string(),
570 entry: "start".to_string(),
571 yaml: flow_yaml.to_string(),
572 json: serde_json::json!({
573 "id": "demo-flow",
574 "type": "messaging",
575 "in": "start",
576 "nodes": {}
577 }),
578 hash_blake3: greentic_flow::flow_bundle::blake3_hex(flow_yaml),
579 nodes: Vec::new(),
580 };
581
582 let wasm_path = temp.path().join("demo-component.wasm");
583 std::fs::write(&wasm_path, b"00").expect("write wasm stub");
584
585 let meta = greentic_pack::builder::PackMeta {
586 pack_version: greentic_pack::builder::PACK_VERSION,
587 pack_id: "gtpack-demo".to_string(),
588 version: semver::Version::new(0, 0, 1),
589 name: "gtpack demo".to_string(),
590 kind: None,
591 description: None,
592 authors: Vec::new(),
593 license: None,
594 homepage: None,
595 support: None,
596 vendor: None,
597 imports: Vec::new(),
598 entry_flows: vec![flow_bundle.id.clone()],
599 created_at_utc: "1970-01-01T00:00:00Z".to_string(),
600 events: None,
601 repo: None,
602 messaging: Some(MessagingSection {
603 adapters: Some(vec![MessagingAdapter {
604 name: "gtpack-adapter".to_string(),
605 kind: MessagingAdapterKind::IngressEgress,
606 component: "demo-component@0.0.1".to_string(),
607 default_flow: Some("flows/messaging/local/default.ygtc".to_string()),
608 custom_flow: None,
609 capabilities: None,
610 }]),
611 }),
612 interfaces: Vec::new(),
613 annotations: serde_json::Map::new(),
614 distribution: None,
615 components: Vec::new(),
616 };
617
618 greentic_pack::builder::PackBuilder::new(meta)
619 .with_flow(flow_bundle)
620 .with_component(greentic_pack::builder::ComponentArtifact {
621 name: "demo-component".to_string(),
622 version: semver::Version::new(0, 0, 1),
623 wasm_path: wasm_path.clone(),
624 schema_json: None,
625 manifest_json: None,
626 capabilities: None,
627 world: None,
628 hash_blake3: None,
629 })
630 .with_signing(greentic_pack::builder::Signing::Dev)
631 .build(>pack_path)
632 .expect("build gtpack");
633
634 let registry =
635 load_adapters_from_pack_files(temp.path(), std::slice::from_ref(>pack_path))
636 .expect("load adapters");
637 let adapter = registry.get("gtpack-adapter").expect("adapter registered");
638 assert_eq!(adapter.pack_id, "gtpack-demo");
639 assert_eq!(adapter.pack_version, "0.0.1");
640 assert_eq!(adapter.component, "demo-component@0.0.1");
641 assert_eq!(
642 adapter.source.as_ref(),
643 Some(>pack_path.canonicalize().unwrap())
644 );
645 }
646
647 #[test]
648 fn allows_absolute_pack_path_outside_root() {
649 let root = TempDir::new().expect("root");
650 let other = TempDir::new().expect("other");
651 let pack_path = other.path().join("pack.yaml");
652 std::fs::write(
653 &pack_path,
654 r#"
655id: absolute-pack
656version: 0.0.1
657messaging:
658 adapters:
659 - name: abs-ingress
660 kind: ingress
661 component: abs@0.0.1
662 "#,
663 )
664 .unwrap();
665
666 let registry =
667 adapters_from_pack_file(root.path(), &pack_path).expect("load absolute pack");
668 let adapter = registry.first().expect("adapter present");
669 assert_eq!(adapter.name, "abs-ingress");
670 assert_eq!(
671 adapter.source.as_ref().map(|p| p.canonicalize().unwrap()),
672 Some(pack_path.canonicalize().unwrap())
673 );
674 }
675
676 #[test]
677 fn infers_platform_from_name_prefix() {
678 assert_eq!(
679 infer_platform_from_adapter_name("slack-main"),
680 Some(Platform::Slack)
681 );
682 assert_eq!(
683 infer_platform_from_adapter_name("telegram-ingress"),
684 Some(Platform::Telegram)
685 );
686 assert_eq!(infer_platform_from_adapter_name("unknown"), None);
687 }
688
689 fn provider_inline_single(component: &str, provider_type: &str) -> ProviderExtensionInline {
690 ProviderExtensionInline {
691 providers: vec![ProviderDecl {
692 provider_type: provider_type.to_string(),
693 capabilities: vec!["capability.test".into()],
694 ops: vec![],
695 config_schema_ref: "schemas/config.json".into(),
696 state_schema_ref: None,
697 runtime: ProviderRuntimeRef {
698 component_ref: component.to_string(),
699 export: "run".into(),
700 world: "greentic:provider/schema-core@1.0.0".into(),
701 },
702 docs_ref: None,
703 }],
704 additional_fields: Default::default(),
705 }
706 }
707
708 #[test]
709 fn extracts_from_provider_extension() {
710 let providers = provider_inline_single("comp@1.0.0", "messaging.demo");
711 let adapters =
712 extract_from_sources("pack.id", "1.0.0", Some(providers), None, None).expect("extract");
713 assert_eq!(adapters.len(), 1);
714 let adapter = &adapters[0];
715 assert_eq!(adapter.name, "messaging.demo");
716 assert_eq!(adapter.component, "comp@1.0.0");
717 assert!(adapter.capabilities.is_some());
718 }
719
720 #[test]
721 fn empty_when_no_sources() {
722 let adapters = extract_from_sources("pack.id", "1.0.0", None, None, None).unwrap();
723 assert!(adapters.is_empty());
724 }
725
726 #[test]
727 fn continues_loading_when_pack_fails() {
728 let temp = TempDir::new().expect("temp dir");
729 let good_path = temp.path().join("good.yaml");
730 let bad_path = temp.path().join("bad.yaml");
731
732 std::fs::write(
733 &good_path,
734 r#"
735id: good-pack
736version: 0.0.1
737messaging:
738 adapters:
739 - name: good-ingress
740 kind: ingress
741 component: good@0.1.0
742"#,
743 )
744 .expect("write good pack");
745 std::fs::write(&bad_path, "not: [valid").expect("write bad pack");
746
747 let (registry, failures) = load_adapters_from_pack_files_with_failures(
748 temp.path(),
749 &[bad_path.clone(), good_path],
750 )
751 .expect("load adapters");
752
753 assert!(registry.get("good-ingress").is_some());
754 assert_eq!(failures.len(), 1);
755 assert_eq!(failures[0].path, bad_path);
756 }
757}