Skip to main content

greentic_setup/
discovery.rs

1//! Pack discovery — scans a bundle directory for `.gtpack` files across
2//! provider domains (messaging, events, oauth) and extracts metadata.
3
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7use serde_cbor::Value as CborValue;
8use zip::result::ZipError;
9
10/// Result of discovering packs in a bundle.
11#[derive(Clone, Debug, Serialize)]
12pub struct DiscoveryResult {
13    pub domains: DetectedDomains,
14    pub providers: Vec<DetectedProvider>,
15    pub app_packs: Vec<DetectedProvider>,
16}
17
18/// Flags indicating which domains are present in the bundle.
19#[derive(Clone, Debug, Serialize)]
20pub struct DetectedDomains {
21    pub messaging: bool,
22    pub events: bool,
23    pub oauth: bool,
24    pub state: bool,
25    pub secrets: bool,
26}
27
28/// Metadata for a discovered provider pack.
29#[derive(Clone, Debug, Serialize)]
30pub struct DetectedProvider {
31    pub provider_id: String,
32    pub display_name: Option<String>,
33    pub domain: String,
34    pub pack_path: PathBuf,
35    pub id_source: ProviderIdSource,
36    pub kind: DetectedPackKind,
37}
38
39/// The broad role this pack plays inside the bundle.
40#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum DetectedPackKind {
43    Provider,
44    App,
45}
46
47/// How the provider ID was determined.
48#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
49#[serde(rename_all = "lowercase")]
50pub enum ProviderIdSource {
51    Manifest,
52    Filename,
53}
54
55/// Metadata extracted from a pack manifest.
56struct PackMeta {
57    pack_id: String,
58    display_name: Option<String>,
59}
60
61/// Public manifest metadata helper returned by [`read_pack_meta`].
62#[derive(Clone, Debug)]
63pub struct DiscoveredPackMeta {
64    pub pack_id: String,
65    pub display_name: Option<String>,
66}
67
68/// Options for discovery.
69#[derive(Default)]
70pub struct DiscoveryOptions {
71    /// Require CBOR manifests (no JSON fallback).
72    pub cbor_only: bool,
73}
74
75/// Well-known provider domain directories.
76const DOMAIN_DIRS: &[(&str, &str)] = &[
77    ("messaging", "providers/messaging"),
78    ("events", "providers/events"),
79    ("oauth", "providers/oauth"),
80    ("state", "providers/state"),
81    ("secrets", "providers/secrets"),
82];
83
84/// Discover provider packs in a bundle root directory.
85pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
86    discover_with_options(root, DiscoveryOptions::default())
87}
88
89/// Discover provider packs with custom options.
90pub fn discover_with_options(
91    root: &Path,
92    options: DiscoveryOptions,
93) -> anyhow::Result<DiscoveryResult> {
94    let mut providers = Vec::new();
95
96    for &(domain, dir) in DOMAIN_DIRS {
97        let providers_dir = root.join(dir);
98        if !providers_dir.exists() {
99            continue;
100        }
101        for entry in std::fs::read_dir(&providers_dir)? {
102            let entry = entry?;
103            if !entry.file_type()?.is_file() {
104                continue;
105            }
106            let path = entry.path();
107            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
108                continue;
109            }
110
111            let (provider_id, display_name, id_source) = if options.cbor_only {
112                match read_pack_meta_cbor_only(&path)? {
113                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
114                    None => return Err(missing_cbor_error(&path)),
115                }
116            } else {
117                match read_pack_meta_from_manifest(&path)? {
118                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
119                    None => {
120                        let stem = path
121                            .file_stem()
122                            .and_then(|v| v.to_str())
123                            .unwrap_or_default()
124                            .to_string();
125                        (stem, None, ProviderIdSource::Filename)
126                    }
127                }
128            };
129
130            providers.push(DetectedProvider {
131                provider_id,
132                display_name,
133                domain: domain.to_string(),
134                pack_path: path,
135                id_source,
136                kind: DetectedPackKind::Provider,
137            });
138        }
139    }
140
141    let mut app_packs = Vec::new();
142    let packs_dir = root.join("packs");
143    if packs_dir.exists() {
144        for entry in std::fs::read_dir(&packs_dir)? {
145            let entry = entry?;
146            if !entry.file_type()?.is_file() {
147                continue;
148            }
149            let path = entry.path();
150            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
151                continue;
152            }
153
154            let (provider_id, display_name, id_source) = if options.cbor_only {
155                match read_pack_meta_cbor_only(&path)? {
156                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
157                    None => return Err(missing_cbor_error(&path)),
158                }
159            } else {
160                match read_pack_meta_from_manifest(&path)? {
161                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
162                    None => {
163                        let stem = path
164                            .file_stem()
165                            .and_then(|v| v.to_str())
166                            .unwrap_or_default()
167                            .to_string();
168                        (stem, None, ProviderIdSource::Filename)
169                    }
170                }
171            };
172
173            app_packs.push(DetectedProvider {
174                provider_id,
175                display_name,
176                domain: "app".to_string(),
177                pack_path: path,
178                id_source,
179                kind: DetectedPackKind::App,
180            });
181        }
182    }
183
184    providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
185    app_packs.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
186
187    let domains = DetectedDomains {
188        messaging: providers.iter().any(|p| p.domain == "messaging"),
189        events: providers.iter().any(|p| p.domain == "events"),
190        oauth: providers.iter().any(|p| p.domain == "oauth"),
191        state: providers.iter().any(|p| p.domain == "state"),
192        secrets: providers.iter().any(|p| p.domain == "secrets"),
193    };
194
195    Ok(DiscoveryResult {
196        domains,
197        providers,
198        app_packs,
199    })
200}
201
202/// Persist discovery results to JSON files in the bundle's runtime state directory.
203pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
204    let runtime_root = root.join("state").join("runtime").join(tenant);
205    std::fs::create_dir_all(&runtime_root)?;
206    let domains_path = runtime_root.join("detected_domains.json");
207    let providers_path = runtime_root.join("detected_providers.json");
208    let app_packs_path = runtime_root.join("detected_app_packs.json");
209    write_json(&domains_path, &discovery.domains)?;
210    write_json(&providers_path, &discovery.providers)?;
211    write_json(&app_packs_path, &discovery.app_packs)?;
212    Ok(())
213}
214
215impl DiscoveryResult {
216    /// Return every discovered pack that can participate in setup.
217    pub fn setup_targets(&self) -> Vec<&DetectedProvider> {
218        self.providers.iter().chain(self.app_packs.iter()).collect()
219    }
220
221    /// Find a discovered setup target by pack/provider ID.
222    pub fn find_setup_target(&self, provider_id: &str) -> Option<&DetectedProvider> {
223        self.providers
224            .iter()
225            .chain(self.app_packs.iter())
226            .find(|pack| pack.provider_id == provider_id)
227    }
228}
229
230/// Read the pack ID and display name from a `.gtpack` manifest when available.
231pub fn read_pack_meta(path: &Path) -> anyhow::Result<Option<DiscoveredPackMeta>> {
232    read_pack_meta_from_manifest(path).map(|meta| {
233        meta.map(|meta| DiscoveredPackMeta {
234            pack_id: meta.pack_id,
235            display_name: meta.display_name,
236        })
237    })
238}
239
240fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
241    if let Some(parent) = path.parent() {
242        std::fs::create_dir_all(parent)?;
243    }
244    let payload = serde_json::to_string_pretty(value)?;
245    std::fs::write(path, payload)?;
246    Ok(())
247}
248
249// ── Manifest reading ────────────────────────────────────────────────────────
250
251fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
252    let file = std::fs::File::open(path)?;
253    match zip::ZipArchive::new(file) {
254        Ok(mut archive) => {
255            if let Some(meta) = read_manifest_cbor(&mut archive)? {
256                return Ok(Some(meta));
257            }
258            if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
259                return Ok(Some(meta));
260            }
261        }
262        Err(_) => {
263            if let Some(meta) = read_manifest_cbor_from_tar(path)? {
264                return Ok(Some(meta));
265            }
266        }
267    }
268    Ok(None)
269}
270
271fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
272    let file = std::fs::File::open(path)?;
273    match zip::ZipArchive::new(file) {
274        Ok(mut archive) => read_manifest_cbor(&mut archive),
275        Err(_) => read_manifest_cbor_from_tar(path),
276    }
277}
278
279fn read_manifest_cbor(
280    archive: &mut zip::ZipArchive<std::fs::File>,
281) -> anyhow::Result<Option<PackMeta>> {
282    let mut file = match archive.by_name("manifest.cbor") {
283        Ok(file) => file,
284        Err(ZipError::FileNotFound) => return Ok(None),
285        Err(err) => return Err(err.into()),
286    };
287    let mut bytes = Vec::new();
288    std::io::Read::read_to_end(&mut file, &mut bytes)?;
289    let value: CborValue = serde_cbor::from_slice(&bytes)?;
290    extract_pack_meta_from_cbor(&value)
291}
292
293fn read_manifest_json(
294    archive: &mut zip::ZipArchive<std::fs::File>,
295    name: &str,
296) -> anyhow::Result<Option<PackMeta>> {
297    let mut file = match archive.by_name(name) {
298        Ok(file) => file,
299        Err(ZipError::FileNotFound) => return Ok(None),
300        Err(err) => return Err(err.into()),
301    };
302    let mut contents = String::new();
303    std::io::Read::read_to_string(&mut file, &mut contents)?;
304    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
305
306    let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
307        obj.get("display_name")
308            .and_then(|v| v.as_str())
309            .or_else(|| obj.get("name").and_then(|v| v.as_str()))
310            .map(String::from)
311    };
312
313    let display_name = resolve_dn(&parsed);
314
315    if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
316        return Ok(Some(PackMeta {
317            pack_id: id.to_string(),
318            display_name,
319        }));
320    }
321    if let Some(meta) = parsed.get("meta")
322        && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
323    {
324        let dn = resolve_dn(meta).or(display_name);
325        return Ok(Some(PackMeta {
326            pack_id: id.to_string(),
327            display_name: dn,
328        }));
329    }
330    Ok(None)
331}
332
333fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
334    let file = std::fs::File::open(path)?;
335    let mut archive = tar::Archive::new(file);
336    for entry in archive.entries()? {
337        let mut entry = entry?;
338        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
339            continue;
340        }
341        let mut bytes = Vec::new();
342        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
343        let value: CborValue = serde_cbor::from_slice(&bytes)?;
344        return extract_pack_meta_from_cbor(&value);
345    }
346    Ok(None)
347}
348
349fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
350    let CborValue::Map(map) = value else {
351        return Ok(None);
352    };
353    let symbols = match map_get(map, "symbols") {
354        Some(CborValue::Map(map)) => Some(map),
355        _ => None,
356    };
357
358    let resolve_display_name =
359        |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
360            map_get(source_map, "display_name")
361                .and_then(|v| match v {
362                    CborValue::Text(text) => Some(text.clone()),
363                    _ => resolve_string_symbol(v, symbols, "display_names")
364                        .ok()
365                        .flatten(),
366                })
367                .or_else(|| {
368                    map_get(source_map, "name").and_then(|v| match v {
369                        CborValue::Text(text) => Some(text.clone()),
370                        _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
371                    })
372                })
373        };
374
375    if let Some(pack_id) = map_get(map, "pack_id")
376        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
377    {
378        return Ok(Some(PackMeta {
379            pack_id: id,
380            display_name: resolve_display_name(map),
381        }));
382    }
383
384    if let Some(CborValue::Map(meta)) = map_get(map, "meta")
385        && let Some(pack_id) = map_get(meta, "pack_id")
386        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
387    {
388        return Ok(Some(PackMeta {
389            pack_id: id,
390            display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
391        }));
392    }
393
394    Ok(None)
395}
396
397fn resolve_string_symbol(
398    value: &CborValue,
399    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
400    symbol_key: &str,
401) -> anyhow::Result<Option<String>> {
402    match value {
403        CborValue::Text(text) => Ok(Some(text.clone())),
404        CborValue::Integer(idx) => {
405            let Some(symbols) = symbols else {
406                return Ok(Some(idx.to_string()));
407            };
408            let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
409                .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
410            else {
411                return Ok(Some(idx.to_string()));
412            };
413            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
414            match values.get(idx) {
415                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
416                _ => Ok(Some(idx.to_string())),
417            }
418        }
419        _ => Ok(None),
420    }
421}
422
423fn map_get<'a>(
424    map: &'a std::collections::BTreeMap<CborValue, CborValue>,
425    key: &str,
426) -> Option<&'a CborValue> {
427    map.iter().find_map(|(k, v)| match k {
428        CborValue::Text(text) if text == key => Some(v),
429        _ => None,
430    })
431}
432
433fn missing_cbor_error(path: &Path) -> anyhow::Error {
434    anyhow::anyhow!(
435        "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
436         Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
437        path.display()
438    )
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::io::Write;
445    use zip::write::{FileOptions, ZipWriter};
446
447    fn write_test_pack(path: &Path, pack_id: &str, display_name: &str) -> anyhow::Result<()> {
448        let file = std::fs::File::create(path)?;
449        let mut writer = ZipWriter::new(file);
450        let options: FileOptions<'_, ()> =
451            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
452        writer.start_file("pack.manifest.json", options)?;
453        writer.write_all(
454            serde_json::json!({
455                "pack_id": pack_id,
456                "display_name": display_name,
457            })
458            .to_string()
459            .as_bytes(),
460        )?;
461        writer.finish()?;
462        Ok(())
463    }
464
465    #[test]
466    fn discover_includes_app_packs_in_setup_targets() -> anyhow::Result<()> {
467        let temp = tempfile::tempdir()?;
468        let root = temp.path();
469        std::fs::create_dir_all(root.join("providers/messaging"))?;
470        std::fs::create_dir_all(root.join("packs"))?;
471
472        write_test_pack(
473            &root
474                .join("providers")
475                .join("messaging")
476                .join("messaging-telegram.gtpack"),
477            "messaging-telegram",
478            "Telegram",
479        )?;
480        write_test_pack(
481            &root.join("packs").join("weather-app.gtpack"),
482            "weather-app",
483            "Weather App",
484        )?;
485
486        let discovered = discover(root)?;
487        assert_eq!(discovered.providers.len(), 1);
488        assert_eq!(discovered.app_packs.len(), 1);
489        assert_eq!(discovered.setup_targets().len(), 2);
490        assert_eq!(discovered.app_packs[0].provider_id, "weather-app");
491        assert_eq!(discovered.app_packs[0].domain, "app");
492        assert_eq!(discovered.app_packs[0].kind, DetectedPackKind::App);
493        Ok(())
494    }
495}