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 base64::Engine;
7use serde::Serialize;
8use serde_cbor::Value as CborValue;
9use zip::result::ZipError;
10
11/// Result of discovering packs in a bundle.
12#[derive(Clone, Debug, Serialize)]
13pub struct DiscoveryResult {
14    pub domains: DetectedDomains,
15    pub providers: Vec<DetectedProvider>,
16    pub app_packs: Vec<DetectedProvider>,
17}
18
19/// Flags indicating which domains are present in the bundle.
20#[derive(Clone, Debug, Serialize)]
21pub struct DetectedDomains {
22    pub messaging: bool,
23    pub events: bool,
24    pub oauth: bool,
25    pub state: bool,
26    pub secrets: bool,
27}
28
29/// Metadata for a discovered provider pack.
30#[derive(Clone, Debug, Serialize)]
31pub struct DetectedProvider {
32    pub provider_id: String,
33    pub display_name: Option<String>,
34    pub domain: String,
35    pub pack_path: PathBuf,
36    pub id_source: ProviderIdSource,
37    pub kind: DetectedPackKind,
38}
39
40/// The broad role this pack plays inside the bundle.
41#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum DetectedPackKind {
44    Provider,
45    App,
46}
47
48/// How the provider ID was determined.
49#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum ProviderIdSource {
52    Manifest,
53    Filename,
54}
55
56/// Metadata extracted from a pack manifest.
57struct PackMeta {
58    pack_id: String,
59    display_name: Option<String>,
60}
61
62/// Public manifest metadata helper returned by [`read_pack_meta`].
63#[derive(Clone, Debug)]
64pub struct DiscoveredPackMeta {
65    pub pack_id: String,
66    pub display_name: Option<String>,
67}
68
69/// Options for discovery.
70#[derive(Default)]
71pub struct DiscoveryOptions {
72    /// Require CBOR manifests (no JSON fallback).
73    pub cbor_only: bool,
74}
75
76/// Well-known provider domain directories.
77const DOMAIN_DIRS: &[(&str, &str)] = &[
78    ("messaging", "providers/messaging"),
79    ("events", "providers/events"),
80    ("oauth", "providers/oauth"),
81    ("state", "providers/state"),
82    ("secrets", "providers/secrets"),
83];
84
85/// Discover provider packs in a bundle root directory.
86pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
87    discover_with_options(root, DiscoveryOptions::default())
88}
89
90/// Discover provider packs with custom options.
91pub fn discover_with_options(
92    root: &Path,
93    options: DiscoveryOptions,
94) -> anyhow::Result<DiscoveryResult> {
95    let mut providers = Vec::new();
96
97    for &(domain, dir) in DOMAIN_DIRS {
98        let providers_dir = root.join(dir);
99        if !providers_dir.exists() {
100            continue;
101        }
102        for entry in std::fs::read_dir(&providers_dir)? {
103            let entry = entry?;
104            if !entry.file_type()?.is_file() {
105                continue;
106            }
107            let path = entry.path();
108            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
109                continue;
110            }
111            let (provider_id, display_name, id_source) =
112                read_pack_identity(&path, options.cbor_only)?;
113            providers.push(DetectedProvider {
114                provider_id,
115                display_name,
116                domain: domain.to_string(),
117                pack_path: path,
118                id_source,
119                kind: DetectedPackKind::Provider,
120            });
121        }
122    }
123
124    // Extension providers land at `providers/<name>.gtpack[/inner.gtpack]`
125    // (depth 1), outside the DOMAIN_DIRS subdirs. Catch them here.
126    let providers_root = root.join("providers");
127    if providers_root.exists() {
128        let known_subdirs: std::collections::HashSet<&str> = DOMAIN_DIRS
129            .iter()
130            .filter_map(|(_, dir)| {
131                std::path::Path::new(dir)
132                    .file_name()
133                    .and_then(|name| name.to_str())
134            })
135            .collect();
136        for entry in std::fs::read_dir(&providers_root)? {
137            let entry = entry?;
138            let entry_path = entry.path();
139            let name_str = entry_path
140                .file_name()
141                .and_then(|s| s.to_str())
142                .unwrap_or_default();
143
144            // Accept both `providers/<name>.gtpack` (file) and the wrapper
145            // dir shape `providers/<name>.gtpack/<inner>.gtpack`.
146            let pack_path = if entry.file_type()?.is_file() {
147                if entry_path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
148                    continue;
149                }
150                entry_path.clone()
151            } else if entry.file_type()?.is_dir() {
152                if known_subdirs.contains(name_str) {
153                    continue;
154                }
155                if !name_str.ends_with(".gtpack") {
156                    continue;
157                }
158                let inner = entry_path.join(name_str);
159                if inner.is_file() {
160                    inner
161                } else {
162                    // Fall back to the first `.gtpack` inside.
163                    match std::fs::read_dir(&entry_path)?
164                        .filter_map(|e| e.ok())
165                        .find(|e| e.path().extension().and_then(|x| x.to_str()) == Some("gtpack"))
166                        .map(|e| e.path())
167                    {
168                        Some(found) => found,
169                        None => continue,
170                    }
171                }
172            } else {
173                continue;
174            };
175
176            let (provider_id, display_name, id_source) =
177                read_pack_identity(&pack_path, options.cbor_only)?;
178
179            // Skip if DOMAIN_DIRS already picked up the same provider.
180            if providers.iter().any(|p| p.provider_id == provider_id) {
181                continue;
182            }
183            let domain = crate::cli_helpers::detect_domain_from_filename(
184                pack_path
185                    .file_name()
186                    .and_then(|s| s.to_str())
187                    .unwrap_or_default(),
188            )
189            .to_string();
190            providers.push(DetectedProvider {
191                provider_id,
192                display_name,
193                domain,
194                pack_path,
195                id_source,
196                kind: DetectedPackKind::Provider,
197            });
198        }
199    }
200
201    let mut app_packs = Vec::new();
202    let packs_dir = root.join("packs");
203    if packs_dir.exists() {
204        for entry in std::fs::read_dir(&packs_dir)? {
205            let entry = entry?;
206            if !entry.file_type()?.is_file() {
207                continue;
208            }
209            let path = entry.path();
210            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
211                continue;
212            }
213            let (provider_id, display_name, id_source) =
214                read_pack_identity(&path, options.cbor_only)?;
215            app_packs.push(DetectedProvider {
216                provider_id,
217                display_name,
218                domain: "app".to_string(),
219                pack_path: path,
220                id_source,
221                kind: DetectedPackKind::App,
222            });
223        }
224    }
225
226    providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
227    app_packs.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
228
229    let domains = DetectedDomains {
230        messaging: providers.iter().any(|p| p.domain == "messaging"),
231        events: providers.iter().any(|p| p.domain == "events"),
232        oauth: providers.iter().any(|p| p.domain == "oauth"),
233        state: providers.iter().any(|p| p.domain == "state"),
234        secrets: providers.iter().any(|p| p.domain == "secrets"),
235    };
236
237    Ok(DiscoveryResult {
238        domains,
239        providers,
240        app_packs,
241    })
242}
243
244/// Persist discovery results to JSON files in the bundle's runtime state directory.
245pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
246    let runtime_root = root.join("state").join("runtime").join(tenant);
247    std::fs::create_dir_all(&runtime_root)?;
248    let domains_path = runtime_root.join("detected_domains.json");
249    let providers_path = runtime_root.join("detected_providers.json");
250    let app_packs_path = runtime_root.join("detected_app_packs.json");
251    write_json(&domains_path, &discovery.domains)?;
252    write_json(&providers_path, &discovery.providers)?;
253    write_json(&app_packs_path, &discovery.app_packs)?;
254    Ok(())
255}
256
257impl DiscoveryResult {
258    /// Return every discovered pack that can participate in setup.
259    pub fn setup_targets(&self) -> Vec<&DetectedProvider> {
260        self.providers.iter().chain(self.app_packs.iter()).collect()
261    }
262
263    /// Find a discovered setup target by pack/provider ID.
264    pub fn find_setup_target(&self, provider_id: &str) -> Option<&DetectedProvider> {
265        self.providers
266            .iter()
267            .chain(self.app_packs.iter())
268            .find(|pack| pack.provider_id == provider_id)
269    }
270}
271
272/// Resolve `(provider_id, display_name, id_source)` for a `.gtpack` file,
273/// using the manifest when available and falling back to the filename stem.
274/// Honours `cbor_only` mode, which requires the manifest-derived metadata.
275fn read_pack_identity(
276    path: &Path,
277    cbor_only: bool,
278) -> anyhow::Result<(String, Option<String>, ProviderIdSource)> {
279    if cbor_only {
280        match read_pack_meta_cbor_only(path)? {
281            Some(meta) => Ok((meta.pack_id, meta.display_name, ProviderIdSource::Manifest)),
282            None => Err(missing_cbor_error(path)),
283        }
284    } else {
285        match read_pack_meta_from_manifest(path)? {
286            Some(meta) => Ok((meta.pack_id, meta.display_name, ProviderIdSource::Manifest)),
287            None => {
288                let stem = path
289                    .file_stem()
290                    .and_then(|v| v.to_str())
291                    .unwrap_or_default()
292                    .to_string();
293                Ok((stem, None, ProviderIdSource::Filename))
294            }
295        }
296    }
297}
298
299/// Read the pack ID and display name from a `.gtpack` manifest when available.
300pub fn read_pack_meta(path: &Path) -> anyhow::Result<Option<DiscoveredPackMeta>> {
301    read_pack_meta_from_manifest(path).map(|meta| {
302        meta.map(|meta| DiscoveredPackMeta {
303            pack_id: meta.pack_id,
304            display_name: meta.display_name,
305        })
306    })
307}
308
309/// Read a top-level manifest extension value by key from a `.gtpack`.
310pub fn read_pack_extension(
311    path: &Path,
312    extension_key: &str,
313) -> anyhow::Result<Option<serde_json::Value>> {
314    let file = std::fs::File::open(path)?;
315    match zip::ZipArchive::new(file) {
316        Ok(mut archive) => {
317            if let Some(value) = read_manifest_extension_cbor(&mut archive, extension_key)? {
318                return Ok(Some(value));
319            }
320            if let Some(value) =
321                read_manifest_extension_json(&mut archive, "pack.manifest.json", extension_key)?
322            {
323                return Ok(Some(value));
324            }
325        }
326        Err(_) => {
327            if let Some(value) = read_manifest_extension_cbor_from_tar(path, extension_key)? {
328                return Ok(Some(value));
329            }
330        }
331    }
332    Ok(None)
333}
334
335fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
336    if let Some(parent) = path.parent() {
337        std::fs::create_dir_all(parent)?;
338    }
339    let payload = serde_json::to_string_pretty(value)?;
340    std::fs::write(path, payload)?;
341    Ok(())
342}
343
344// ── Manifest reading ────────────────────────────────────────────────────────
345
346fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
347    let file = std::fs::File::open(path)?;
348    match zip::ZipArchive::new(file) {
349        Ok(mut archive) => {
350            if let Some(meta) = read_manifest_cbor(&mut archive)? {
351                return Ok(Some(meta));
352            }
353            if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
354                return Ok(Some(meta));
355            }
356        }
357        Err(_) => {
358            if let Some(meta) = read_manifest_cbor_from_tar(path)? {
359                return Ok(Some(meta));
360            }
361        }
362    }
363    Ok(None)
364}
365
366fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
367    let file = std::fs::File::open(path)?;
368    match zip::ZipArchive::new(file) {
369        Ok(mut archive) => read_manifest_cbor(&mut archive),
370        Err(_) => read_manifest_cbor_from_tar(path),
371    }
372}
373
374fn read_manifest_cbor(
375    archive: &mut zip::ZipArchive<std::fs::File>,
376) -> anyhow::Result<Option<PackMeta>> {
377    let mut file = match archive.by_name("manifest.cbor") {
378        Ok(file) => file,
379        Err(ZipError::FileNotFound) => return Ok(None),
380        Err(err) => return Err(err.into()),
381    };
382    let mut bytes = Vec::new();
383    std::io::Read::read_to_end(&mut file, &mut bytes)?;
384    let value: CborValue = serde_cbor::from_slice(&bytes)?;
385    extract_pack_meta_from_cbor(&value)
386}
387
388fn read_manifest_extension_cbor(
389    archive: &mut zip::ZipArchive<std::fs::File>,
390    extension_key: &str,
391) -> anyhow::Result<Option<serde_json::Value>> {
392    let mut file = match archive.by_name("manifest.cbor") {
393        Ok(file) => file,
394        Err(ZipError::FileNotFound) => return Ok(None),
395        Err(err) => return Err(err.into()),
396    };
397    let mut bytes = Vec::new();
398    std::io::Read::read_to_end(&mut file, &mut bytes)?;
399    let value: CborValue = serde_cbor::from_slice(&bytes)?;
400    let CborValue::Map(map) = &value else {
401        return Ok(None);
402    };
403    if let Some(value) = map_get(map, extension_key) {
404        return Ok(Some(cbor_to_json(value)));
405    }
406    let Some(CborValue::Map(extensions)) = map_get(map, "extensions") else {
407        return Ok(None);
408    };
409    Ok(map_get(extensions, extension_key).map(cbor_to_json))
410}
411
412fn read_manifest_json(
413    archive: &mut zip::ZipArchive<std::fs::File>,
414    name: &str,
415) -> anyhow::Result<Option<PackMeta>> {
416    let mut file = match archive.by_name(name) {
417        Ok(file) => file,
418        Err(ZipError::FileNotFound) => return Ok(None),
419        Err(err) => return Err(err.into()),
420    };
421    let mut contents = String::new();
422    std::io::Read::read_to_string(&mut file, &mut contents)?;
423    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
424
425    let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
426        obj.get("display_name")
427            .and_then(|v| v.as_str())
428            .or_else(|| obj.get("name").and_then(|v| v.as_str()))
429            .map(String::from)
430    };
431
432    let display_name = resolve_dn(&parsed);
433
434    if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
435        return Ok(Some(PackMeta {
436            pack_id: id.to_string(),
437            display_name,
438        }));
439    }
440    if let Some(meta) = parsed.get("meta")
441        && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
442    {
443        let dn = resolve_dn(meta).or(display_name);
444        return Ok(Some(PackMeta {
445            pack_id: id.to_string(),
446            display_name: dn,
447        }));
448    }
449    Ok(None)
450}
451
452fn read_manifest_extension_json(
453    archive: &mut zip::ZipArchive<std::fs::File>,
454    name: &str,
455    extension_key: &str,
456) -> anyhow::Result<Option<serde_json::Value>> {
457    let mut file = match archive.by_name(name) {
458        Ok(file) => file,
459        Err(ZipError::FileNotFound) => return Ok(None),
460        Err(err) => return Err(err.into()),
461    };
462    let mut contents = String::new();
463    std::io::Read::read_to_string(&mut file, &mut contents)?;
464    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
465    Ok(parsed.get(extension_key).cloned().or_else(|| {
466        parsed
467            .get("extensions")
468            .and_then(|extensions| extensions.get(extension_key))
469            .cloned()
470    }))
471}
472
473fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
474    let file = std::fs::File::open(path)?;
475    let mut archive = tar::Archive::new(file);
476    for entry in archive.entries()? {
477        let mut entry = entry?;
478        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
479            continue;
480        }
481        let mut bytes = Vec::new();
482        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
483        let value: CborValue = serde_cbor::from_slice(&bytes)?;
484        return extract_pack_meta_from_cbor(&value);
485    }
486    Ok(None)
487}
488
489fn read_manifest_extension_cbor_from_tar(
490    path: &Path,
491    extension_key: &str,
492) -> anyhow::Result<Option<serde_json::Value>> {
493    let file = std::fs::File::open(path)?;
494    let mut archive = tar::Archive::new(file);
495    for entry in archive.entries()? {
496        let mut entry = entry?;
497        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
498            continue;
499        }
500        let mut bytes = Vec::new();
501        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
502        let value: CborValue = serde_cbor::from_slice(&bytes)?;
503        let CborValue::Map(map) = &value else {
504            return Ok(None);
505        };
506        return Ok(map_get(map, extension_key).map(cbor_to_json));
507    }
508    Ok(None)
509}
510
511fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
512    let CborValue::Map(map) = value else {
513        return Ok(None);
514    };
515    let symbols = match map_get(map, "symbols") {
516        Some(CborValue::Map(map)) => Some(map),
517        _ => None,
518    };
519
520    let resolve_display_name =
521        |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
522            map_get(source_map, "display_name")
523                .and_then(|v| match v {
524                    CborValue::Text(text) => Some(text.clone()),
525                    _ => resolve_string_symbol(v, symbols, "display_names")
526                        .ok()
527                        .flatten(),
528                })
529                .or_else(|| {
530                    map_get(source_map, "name").and_then(|v| match v {
531                        CborValue::Text(text) => Some(text.clone()),
532                        _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
533                    })
534                })
535        };
536
537    if let Some(pack_id) = map_get(map, "pack_id")
538        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
539    {
540        return Ok(Some(PackMeta {
541            pack_id: id,
542            display_name: resolve_display_name(map),
543        }));
544    }
545
546    if let Some(CborValue::Map(meta)) = map_get(map, "meta")
547        && let Some(pack_id) = map_get(meta, "pack_id")
548        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
549    {
550        return Ok(Some(PackMeta {
551            pack_id: id,
552            display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
553        }));
554    }
555
556    Ok(None)
557}
558
559fn resolve_string_symbol(
560    value: &CborValue,
561    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
562    symbol_key: &str,
563) -> anyhow::Result<Option<String>> {
564    match value {
565        CborValue::Text(text) => Ok(Some(text.clone())),
566        CborValue::Integer(idx) => {
567            let Some(symbols) = symbols else {
568                return Ok(Some(idx.to_string()));
569            };
570            let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
571                .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
572            else {
573                return Ok(Some(idx.to_string()));
574            };
575            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
576            match values.get(idx) {
577                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
578                _ => Ok(Some(idx.to_string())),
579            }
580        }
581        _ => Ok(None),
582    }
583}
584
585fn map_get<'a>(
586    map: &'a std::collections::BTreeMap<CborValue, CborValue>,
587    key: &str,
588) -> Option<&'a CborValue> {
589    map.iter().find_map(|(k, v)| match k {
590        CborValue::Text(text) if text == key => Some(v),
591        _ => None,
592    })
593}
594
595fn cbor_to_json(value: &CborValue) -> serde_json::Value {
596    match value {
597        CborValue::Null => serde_json::Value::Null,
598        CborValue::Bool(v) => serde_json::Value::Bool(*v),
599        CborValue::Integer(v) => i64::try_from(*v)
600            .map(serde_json::Number::from)
601            .map(serde_json::Value::Number)
602            .unwrap_or_else(|_| serde_json::Value::String(v.to_string())),
603        CborValue::Float(v) => serde_json::Number::from_f64(*v)
604            .map(serde_json::Value::Number)
605            .unwrap_or(serde_json::Value::Null),
606        CborValue::Bytes(bytes) => {
607            serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(bytes))
608        }
609        CborValue::Text(text) => serde_json::Value::String(text.clone()),
610        CborValue::Array(values) => {
611            serde_json::Value::Array(values.iter().map(cbor_to_json).collect())
612        }
613        CborValue::Map(map) => {
614            let mut obj = serde_json::Map::new();
615            for (key, value) in map {
616                let key = match key {
617                    CborValue::Text(text) => text.clone(),
618                    CborValue::Integer(value) => value.to_string(),
619                    other => serde_json::to_string(&cbor_to_json(other)).unwrap_or_default(),
620                };
621                obj.insert(key, cbor_to_json(value));
622            }
623            serde_json::Value::Object(obj)
624        }
625        _ => serde_json::Value::Null,
626    }
627}
628
629fn missing_cbor_error(path: &Path) -> anyhow::Error {
630    anyhow::anyhow!(
631        "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
632         Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
633        path.display()
634    )
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use std::io::Write;
641    use zip::write::{FileOptions, ZipWriter};
642
643    fn write_test_pack(path: &Path, pack_id: &str, display_name: &str) -> anyhow::Result<()> {
644        let file = std::fs::File::create(path)?;
645        let mut writer = ZipWriter::new(file);
646        let options: FileOptions<'_, ()> =
647            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
648        writer.start_file("pack.manifest.json", options)?;
649        writer.write_all(
650            serde_json::json!({
651                "pack_id": pack_id,
652                "display_name": display_name,
653            })
654            .to_string()
655            .as_bytes(),
656        )?;
657        writer.finish()?;
658        Ok(())
659    }
660
661    fn write_test_pack_manifest(path: &Path, manifest: serde_json::Value) -> anyhow::Result<()> {
662        let file = std::fs::File::create(path)?;
663        let mut writer = ZipWriter::new(file);
664        let options: FileOptions<'_, ()> =
665            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
666        writer.start_file("pack.manifest.json", options)?;
667        writer.write_all(manifest.to_string().as_bytes())?;
668        writer.finish()?;
669        Ok(())
670    }
671
672    #[test]
673    fn discover_picks_up_extension_provider_in_wrapper_dir() -> anyhow::Result<()> {
674        // Layout the bundle wizard produces for extension providers (declared
675        // via `extension_provider_entries`): a directory under `providers/`
676        // ending in `.gtpack` that contains the actual `.gtpack` file inside.
677        // Before this fix the depth-1 directory was invisible to discovery,
678        // which silently dropped the provider's setup-answers page and
679        // prevented secrets like `jwt_signing_key` from ever reaching the
680        // dev secrets store at runtime.
681        let temp = tempfile::tempdir()?;
682        let root = temp.path();
683        let wrapper = root.join("providers").join("messaging-webchat-gui.gtpack");
684        std::fs::create_dir_all(&wrapper)?;
685        let inner = wrapper.join("messaging-webchat-gui.gtpack");
686        write_test_pack(&inner, "messaging-webchat-gui", "WebChat GUI")?;
687
688        let discovered = discover(root)?;
689        assert_eq!(discovered.providers.len(), 1);
690        let provider = &discovered.providers[0];
691        assert_eq!(provider.provider_id, "messaging-webchat-gui");
692        assert_eq!(provider.domain, "messaging");
693        assert_eq!(provider.kind, DetectedPackKind::Provider);
694        // pack_path must resolve to the inner zip so downstream consumers
695        // (setup_to_formspec::pack_to_form_spec, load_setup_spec) can open it
696        // with `File::open` + `ZipArchive::new`.
697        assert_eq!(provider.pack_path, inner);
698        assert!(
699            discovered
700                .find_setup_target("messaging-webchat-gui")
701                .is_some()
702        );
703        Ok(())
704    }
705
706    #[test]
707    fn discover_does_not_double_count_when_pack_lives_in_both_locations() -> anyhow::Result<()> {
708        // Defensive: if a pack happens to be installed both under the canonical
709        // `providers/<domain>/` dir AND as a wrapper dir at depth 1, we should
710        // pick exactly one (the DOMAIN_DIRS pass wins because it runs first).
711        let temp = tempfile::tempdir()?;
712        let root = temp.path();
713        std::fs::create_dir_all(root.join("providers/messaging"))?;
714        write_test_pack(
715            &root
716                .join("providers/messaging")
717                .join("messaging-telegram.gtpack"),
718            "messaging-telegram",
719            "Telegram",
720        )?;
721        let wrapper = root.join("providers").join("messaging-telegram.gtpack");
722        std::fs::create_dir_all(&wrapper)?;
723        write_test_pack(
724            &wrapper.join("messaging-telegram.gtpack"),
725            "messaging-telegram",
726            "Telegram",
727        )?;
728
729        let discovered = discover(root)?;
730        // Either path is acceptable for the de-duped entry; we just must not
731        // see the same provider twice.
732        let matching: Vec<_> = discovered
733            .providers
734            .iter()
735            .filter(|p| p.provider_id == "messaging-telegram")
736            .collect();
737        assert_eq!(
738            matching.len(),
739            1,
740            "expected exactly one entry, got {matching:?}"
741        );
742        Ok(())
743    }
744
745    #[test]
746    fn discover_includes_app_packs_in_setup_targets() -> anyhow::Result<()> {
747        let temp = tempfile::tempdir()?;
748        let root = temp.path();
749        std::fs::create_dir_all(root.join("providers/messaging"))?;
750        std::fs::create_dir_all(root.join("packs"))?;
751
752        write_test_pack(
753            &root
754                .join("providers")
755                .join("messaging")
756                .join("messaging-telegram.gtpack"),
757            "messaging-telegram",
758            "Telegram",
759        )?;
760        write_test_pack(
761            &root.join("packs").join("weather-app.gtpack"),
762            "weather-app",
763            "Weather App",
764        )?;
765
766        let discovered = discover(root)?;
767        assert_eq!(discovered.providers.len(), 1);
768        assert_eq!(discovered.app_packs.len(), 1);
769        assert_eq!(discovered.setup_targets().len(), 2);
770        assert_eq!(discovered.app_packs[0].provider_id, "weather-app");
771        assert_eq!(discovered.app_packs[0].domain, "app");
772        assert_eq!(discovered.app_packs[0].kind, DetectedPackKind::App);
773        Ok(())
774    }
775
776    #[test]
777    fn read_pack_extension_reads_json_manifest_extension() -> anyhow::Result<()> {
778        let temp = tempfile::tempdir()?;
779        let pack = temp.path().join("messaging-example.gtpack");
780        write_test_pack_manifest(
781            &pack,
782            serde_json::json!({
783                "pack_id": "messaging-example",
784                "extensions": {
785                    "messaging.oauth.v1": {
786                        "token_url": "https://example.com/token",
787                        "secret_keys": ["EXAMPLE_TOKEN"]
788                    }
789                }
790            }),
791        )?;
792        let extension = read_pack_extension(&pack, "messaging.oauth.v1")?.unwrap();
793        assert_eq!(extension["token_url"], "https://example.com/token");
794        Ok(())
795    }
796
797    #[test]
798    fn read_pack_extension_reads_cbor_manifest_extensions_map() -> anyhow::Result<()> {
799        let temp = tempfile::tempdir()?;
800        let pack = temp.path().join("messaging-example.gtpack");
801        let file = std::fs::File::create(&pack)?;
802        let mut writer = zip::ZipWriter::new(file);
803        let options: zip::write::FileOptions<'_, ()> =
804            zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
805        writer.start_file("manifest.cbor", options)?;
806        let manifest = serde_cbor::value::to_value(serde_json::json!({
807            "pack_id": "messaging-example",
808            "extensions": {
809                "messaging.oauth.v1": {
810                    "inline": {
811                        "token_url": "https://example.com/token"
812                    }
813                }
814            }
815        }))?;
816        writer.write_all(&serde_cbor::to_vec(&manifest)?)?;
817        writer.finish()?;
818
819        let extension = read_pack_extension(&pack, "messaging.oauth.v1")?.unwrap();
820        assert_eq!(
821            extension["inline"]["token_url"],
822            "https://example.com/token"
823        );
824        Ok(())
825    }
826}