Skip to main content

gsm_core/
adapter_registry.rs

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    /// Returns true if the adapter can be used for ingress.
39    pub fn allows_ingress(&self) -> bool {
40        matches!(
41            self.kind,
42            MessagingAdapterKind::Ingress | MessagingAdapterKind::IngressEgress
43        )
44    }
45
46    /// Returns true if the adapter can be used for egress.
47    pub fn allows_egress(&self) -> bool {
48        matches!(
49            self.kind,
50            MessagingAdapterKind::Egress | MessagingAdapterKind::IngressEgress
51        )
52    }
53
54    /// Returns a flow path to use, preferring custom_flow if set.
55    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    // Greentic pack reader converts gpack manifests into PackManifest and drops extensions.
221    // Re-read the raw manifest to recover provider extensions while keeping messaging adapters
222    // for compatibility.
223    // Pull manifest.cbor out of the archive without full validation to avoid reimplementing
224    // the reader; fall back to the already-parsed manifest if extraction fails.
225    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    // Minimal mapping; treat provider capabilities as-is for now.
351    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    // validate uniqueness (MessagingSection::validate already enforces, but keep defensive)
370    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
404/// Best-effort inference of `Platform` from an adapter name prefix.
405pub 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        // Sanity: ensure extension survives round-trip decode.
503        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(&gtpack_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(&gtpack_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(&gtpack_path)
632            .expect("build gtpack");
633
634        let registry =
635            load_adapters_from_pack_files(temp.path(), std::slice::from_ref(&gtpack_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(&gtpack_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}