gsm_core/
adapter_registry.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::Platform;
6use crate::path_safety::normalize_under_root;
7use anyhow::{Context, Result, anyhow, bail};
8use greentic_pack::builder::PackManifest;
9use greentic_pack::messaging::{
10    MessagingAdapterCapabilities, MessagingAdapterKind, MessagingSection,
11};
12use greentic_pack::reader::{SigningPolicy, open_pack};
13
14#[derive(Debug, serde::Deserialize)]
15struct PackSpec {
16    id: String,
17    version: String,
18    #[serde(default)]
19    messaging: Option<MessagingSection>,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
23pub struct AdapterDescriptor {
24    pub pack_id: String,
25    pub pack_version: String,
26    pub name: String,
27    pub kind: MessagingAdapterKind,
28    pub component: String,
29    pub default_flow: Option<String>,
30    pub custom_flow: Option<String>,
31    pub capabilities: Option<MessagingAdapterCapabilities>,
32    pub source: Option<PathBuf>,
33}
34
35impl AdapterDescriptor {
36    /// Returns true if the adapter can be used for ingress.
37    pub fn allows_ingress(&self) -> bool {
38        matches!(
39            self.kind,
40            MessagingAdapterKind::Ingress | MessagingAdapterKind::IngressEgress
41        )
42    }
43
44    /// Returns true if the adapter can be used for egress.
45    pub fn allows_egress(&self) -> bool {
46        matches!(
47            self.kind,
48            MessagingAdapterKind::Egress | MessagingAdapterKind::IngressEgress
49        )
50    }
51
52    /// Returns a flow path to use, preferring custom_flow if set.
53    pub fn flow_path(&self) -> Option<&str> {
54        self.custom_flow.as_deref().or(self.default_flow.as_deref())
55    }
56}
57
58#[derive(Default, Clone, Debug)]
59pub struct AdapterRegistry {
60    adapters: HashMap<String, AdapterDescriptor>,
61}
62
63impl AdapterRegistry {
64    pub fn load_from_paths(root: &Path, paths: &[PathBuf]) -> Result<Self> {
65        load_adapters_from_pack_files(root, paths)
66    }
67
68    pub fn register(&mut self, adapter: AdapterDescriptor) -> Result<()> {
69        if self.adapters.contains_key(&adapter.name) {
70            bail!("duplicate adapter registration for {}", adapter.name);
71        }
72        self.adapters.insert(adapter.name.clone(), adapter);
73        Ok(())
74    }
75
76    pub fn get(&self, name: &str) -> Option<&AdapterDescriptor> {
77        self.adapters.get(name)
78    }
79
80    pub fn all(&self) -> Vec<AdapterDescriptor> {
81        self.adapters.values().cloned().collect()
82    }
83
84    pub fn by_kind(&self, kind: MessagingAdapterKind) -> Vec<AdapterDescriptor> {
85        self.adapters
86            .values()
87            .filter(|a| a.kind == kind)
88            .cloned()
89            .collect()
90    }
91
92    pub fn names(&self) -> Vec<String> {
93        self.adapters.keys().cloned().collect()
94    }
95
96    pub fn is_empty(&self) -> bool {
97        self.adapters.is_empty()
98    }
99}
100
101pub fn load_adapters_from_pack_files(root: &Path, paths: &[PathBuf]) -> Result<AdapterRegistry> {
102    let root = root
103        .canonicalize()
104        .with_context(|| format!("failed to canonicalize packs root {}", root.display()))?;
105    let mut registry = AdapterRegistry::default();
106    for path in paths {
107        let adapters = adapters_from_pack_file(&root, path)
108            .with_context(|| format!("failed to load pack {}", path.display()))?;
109        for adapter in adapters {
110            registry
111                .register(adapter)
112                .with_context(|| format!("failed to register adapters from {}", path.display()))?;
113        }
114    }
115    Ok(registry)
116}
117
118pub fn adapters_from_pack_file(root: &Path, path: &Path) -> Result<Vec<AdapterDescriptor>> {
119    let safe_path = resolve_pack_path(root, path)?;
120    let ext = safe_path
121        .extension()
122        .and_then(|s| s.to_str())
123        .map(|s| s.to_ascii_lowercase());
124    match ext.as_deref() {
125        Some("gtpack") => adapters_from_gtpack(&safe_path),
126        _ => adapters_from_pack_yaml(&safe_path),
127    }
128}
129
130fn resolve_pack_path(root: &Path, path: &Path) -> Result<PathBuf> {
131    if path.is_absolute() {
132        let canonical_path = path
133            .canonicalize()
134            .with_context(|| format!("failed to canonicalize {}", path.display()))?;
135        if !canonical_path.starts_with(root) {
136            bail!(
137                "pack path {} must be under {}",
138                canonical_path.display(),
139                root.display()
140            );
141        }
142        Ok(canonical_path)
143    } else {
144        normalize_under_root(root, path)
145    }
146}
147
148fn adapters_from_pack_yaml(path: &Path) -> Result<Vec<AdapterDescriptor>> {
149    let raw = fs::read_to_string(path)
150        .with_context(|| format!("failed to read pack file {}", path.display()))?;
151    let spec: PackSpec = serde_yaml_bw::from_str(&raw)
152        .with_context(|| format!("{} is not a valid pack spec", path.display()))?;
153    validate_pack_spec(&spec)?;
154    extract_adapters(&spec.id, &spec.version, spec.messaging.as_ref(), Some(path))
155}
156
157fn adapters_from_gtpack(path: &Path) -> Result<Vec<AdapterDescriptor>> {
158    let pack = open_pack(path, SigningPolicy::DevOk)
159        .map_err(|err| anyhow!(err.message))
160        .with_context(|| format!("failed to open {}", path.display()))?;
161    extract_adapters_from_manifest(&pack.manifest, Some(path))
162}
163
164fn validate_pack_spec(spec: &PackSpec) -> Result<()> {
165    if spec.id.trim().is_empty() {
166        bail!("pack id must not be empty");
167    }
168    if spec.version.trim().is_empty() {
169        bail!("pack version must not be empty");
170    }
171    if let Some(messaging) = &spec.messaging {
172        messaging.validate()?;
173    }
174    Ok(())
175}
176
177fn extract_adapters_from_manifest(
178    manifest: &PackManifest,
179    source: Option<&Path>,
180) -> Result<Vec<AdapterDescriptor>> {
181    extract_adapters(
182        &manifest.meta.pack_id,
183        &manifest.meta.version.to_string(),
184        manifest.meta.messaging.as_ref(),
185        source,
186    )
187}
188
189fn extract_adapters(
190    pack_id: &str,
191    pack_version: &str,
192    messaging: Option<&MessagingSection>,
193    source: Option<&Path>,
194) -> Result<Vec<AdapterDescriptor>> {
195    let mut out = Vec::new();
196    let messaging = match messaging {
197        Some(section) => section,
198        None => return Ok(out),
199    };
200    let adapters = match &messaging.adapters {
201        Some(list) => list,
202        None => return Ok(out),
203    };
204    // validate uniqueness (MessagingSection::validate already enforces, but keep defensive)
205    let mut seen = std::collections::BTreeSet::new();
206    for adapter in adapters {
207        if !seen.insert(&adapter.name) {
208            bail!("duplicate messaging adapter name: {}", adapter.name);
209        }
210        out.push(AdapterDescriptor {
211            pack_id: pack_id.to_string(),
212            pack_version: pack_version.to_string(),
213            name: adapter.name.clone(),
214            kind: adapter.kind.clone(),
215            component: adapter.component.clone(),
216            default_flow: adapter.default_flow.clone(),
217            custom_flow: adapter.custom_flow.clone(),
218            capabilities: adapter.capabilities.clone(),
219            source: source.map(Path::to_path_buf),
220        });
221    }
222    Ok(out)
223}
224
225/// Best-effort inference of `Platform` from an adapter name prefix.
226pub fn infer_platform_from_adapter_name(name: &str) -> Option<Platform> {
227    let lowered = name.to_ascii_lowercase();
228    if lowered.starts_with("slack") {
229        Some(Platform::Slack)
230    } else if lowered.starts_with("teams") {
231        Some(Platform::Teams)
232    } else if lowered.starts_with("webex") {
233        Some(Platform::Webex)
234    } else if lowered.starts_with("webchat") {
235        Some(Platform::WebChat)
236    } else if lowered.starts_with("whatsapp") {
237        Some(Platform::WhatsApp)
238    } else if lowered.starts_with("telegram") {
239        Some(Platform::Telegram)
240    } else {
241        None
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use greentic_pack::messaging::MessagingAdapter;
249    use tempfile::TempDir;
250
251    #[test]
252    fn loads_slack_pack() {
253        let packs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packs");
254        let base = packs_root
255            .join("messaging/slack.yaml")
256            .canonicalize()
257            .expect("canonicalize pack path");
258        let registry =
259            load_adapters_from_pack_files(packs_root.as_path(), std::slice::from_ref(&base))
260                .unwrap();
261        let adapter = registry.get("slack-main").expect("adapter registered");
262        assert_eq!(adapter.pack_id, "greentic-messaging-slack");
263        assert_eq!(adapter.kind, MessagingAdapterKind::IngressEgress);
264        assert_eq!(adapter.component, "slack-adapter@1.0.0");
265        assert_eq!(
266            adapter.default_flow.as_deref(),
267            Some("flows/messaging/slack/default.ygtc")
268        );
269        assert_eq!(adapter.source.as_ref(), Some(&base));
270    }
271
272    #[test]
273    fn by_kind_filters() {
274        let packs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packs");
275        let paths = vec![
276            packs_root
277                .join("messaging/slack.yaml")
278                .canonicalize()
279                .expect("canonicalize pack path"),
280            packs_root
281                .join("messaging/telegram.yaml")
282                .canonicalize()
283                .expect("canonicalize pack path"),
284        ];
285        let registry = load_adapters_from_pack_files(packs_root.as_path(), &paths).unwrap();
286        let ingress = registry.by_kind(MessagingAdapterKind::Ingress);
287        assert!(ingress.iter().any(|a| a.name == "telegram-ingress"));
288        let egress = registry.by_kind(MessagingAdapterKind::Egress);
289        assert!(egress.iter().any(|a| a.name == "telegram-egress"));
290        let both = registry.by_kind(MessagingAdapterKind::IngressEgress);
291        assert!(both.iter().any(|a| a.name == "slack-main"));
292    }
293
294    #[test]
295    fn loads_gtpack_archive() {
296        let temp = TempDir::new().expect("temp dir");
297        let gtpack_path = temp.path().join("demo.gtpack");
298
299        let flow_yaml = r#"id: demo-flow
300type: messaging
301in: start
302nodes: {}
303"#;
304        let flow_bundle = greentic_flow::flow_bundle::FlowBundle {
305            id: "demo-flow".to_string(),
306            kind: "messaging".to_string(),
307            entry: "start".to_string(),
308            yaml: flow_yaml.to_string(),
309            json: serde_json::json!({
310                "id": "demo-flow",
311                "type": "messaging",
312                "in": "start",
313                "nodes": {}
314            }),
315            hash_blake3: greentic_flow::flow_bundle::blake3_hex(flow_yaml),
316            nodes: Vec::new(),
317        };
318
319        let wasm_path = temp.path().join("demo-component.wasm");
320        std::fs::write(&wasm_path, b"00").expect("write wasm stub");
321
322        let meta = greentic_pack::builder::PackMeta {
323            pack_version: greentic_pack::builder::PACK_VERSION,
324            pack_id: "gtpack-demo".to_string(),
325            version: semver::Version::new(0, 0, 1),
326            name: "gtpack demo".to_string(),
327            kind: None,
328            description: None,
329            authors: Vec::new(),
330            license: None,
331            homepage: None,
332            support: None,
333            vendor: None,
334            imports: Vec::new(),
335            entry_flows: vec![flow_bundle.id.clone()],
336            created_at_utc: "1970-01-01T00:00:00Z".to_string(),
337            events: None,
338            repo: None,
339            messaging: Some(MessagingSection {
340                adapters: Some(vec![MessagingAdapter {
341                    name: "gtpack-adapter".to_string(),
342                    kind: MessagingAdapterKind::IngressEgress,
343                    component: "demo-component@0.0.1".to_string(),
344                    default_flow: Some("flows/messaging/local/default.ygtc".to_string()),
345                    custom_flow: None,
346                    capabilities: None,
347                }]),
348            }),
349            interfaces: Vec::new(),
350            annotations: serde_json::Map::new(),
351            distribution: None,
352            components: Vec::new(),
353        };
354
355        greentic_pack::builder::PackBuilder::new(meta)
356            .with_flow(flow_bundle)
357            .with_component(greentic_pack::builder::ComponentArtifact {
358                name: "demo-component".to_string(),
359                version: semver::Version::new(0, 0, 1),
360                wasm_path: wasm_path.clone(),
361                schema_json: None,
362                manifest_json: None,
363                capabilities: None,
364                world: None,
365                hash_blake3: None,
366            })
367            .with_signing(greentic_pack::builder::Signing::Dev)
368            .build(&gtpack_path)
369            .expect("build gtpack");
370
371        let registry =
372            load_adapters_from_pack_files(temp.path(), std::slice::from_ref(&gtpack_path))
373                .expect("load adapters");
374        let adapter = registry.get("gtpack-adapter").expect("adapter registered");
375        assert_eq!(adapter.pack_id, "gtpack-demo");
376        assert_eq!(adapter.pack_version, "0.0.1");
377        assert_eq!(adapter.component, "demo-component@0.0.1");
378        assert_eq!(
379            adapter.source.as_ref(),
380            Some(&gtpack_path.canonicalize().unwrap())
381        );
382    }
383
384    #[test]
385    fn infers_platform_from_name_prefix() {
386        assert_eq!(
387            infer_platform_from_adapter_name("slack-main"),
388            Some(Platform::Slack)
389        );
390        assert_eq!(
391            infer_platform_from_adapter_name("telegram-ingress"),
392            Some(Platform::Telegram)
393        );
394        assert_eq!(infer_platform_from_adapter_name("unknown"), None);
395    }
396}