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 pub fn allows_ingress(&self) -> bool {
38 matches!(
39 self.kind,
40 MessagingAdapterKind::Ingress | MessagingAdapterKind::IngressEgress
41 )
42 }
43
44 pub fn allows_egress(&self) -> bool {
46 matches!(
47 self.kind,
48 MessagingAdapterKind::Egress | MessagingAdapterKind::IngressEgress
49 )
50 }
51
52 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 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
225pub 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(>pack_path)
369 .expect("build gtpack");
370
371 let registry =
372 load_adapters_from_pack_files(temp.path(), std::slice::from_ref(>pack_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(>pack_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}