Skip to main content

greentic_operator/domains/
mod.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use serde::{Deserialize, Serialize};
7use serde_cbor::Value as CborValue;
8use zip::result::ZipError;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)]
11pub enum Domain {
12    Messaging,
13    Events,
14    Secrets,
15    OAuth,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DomainAction {
20    Setup,
21    Diagnostics,
22    Verify,
23}
24
25#[derive(Clone, Debug)]
26pub struct DomainConfig {
27    pub providers_dir: &'static str,
28    pub setup_flow: &'static str,
29    pub diagnostics_flow: &'static str,
30    pub verify_flows: &'static [&'static str],
31}
32
33#[derive(Clone, Debug, Serialize)]
34pub struct ProviderPack {
35    pub pack_id: String,
36    pub file_name: String,
37    pub path: PathBuf,
38    pub entry_flows: Vec<String>,
39}
40
41#[derive(Clone, Debug, Serialize)]
42pub struct PlannedRun {
43    pub pack: ProviderPack,
44    pub flow_id: String,
45}
46
47pub fn config(domain: Domain) -> DomainConfig {
48    match domain {
49        Domain::Messaging => DomainConfig {
50            providers_dir: "providers/messaging",
51            setup_flow: "setup_default",
52            diagnostics_flow: "diagnostics",
53            verify_flows: &["verify_webhooks"],
54        },
55        Domain::Events => DomainConfig {
56            providers_dir: "providers/events",
57            setup_flow: "setup_default",
58            diagnostics_flow: "diagnostics",
59            verify_flows: &["verify_subscriptions"],
60        },
61        Domain::Secrets => DomainConfig {
62            providers_dir: "providers/secrets",
63            setup_flow: "setup_default",
64            diagnostics_flow: "diagnostics",
65            verify_flows: &[],
66        },
67        Domain::OAuth => DomainConfig {
68            providers_dir: "providers/oauth",
69            setup_flow: "setup_default",
70            diagnostics_flow: "diagnostics",
71            verify_flows: &[],
72        },
73    }
74}
75
76pub fn validator_pack_path(root: &Path, domain: Domain) -> Option<PathBuf> {
77    let name = match domain {
78        Domain::Messaging => "validators-messaging.gtpack",
79        Domain::Events => "validators-events.gtpack",
80        Domain::Secrets => "validators-secrets.gtpack",
81        Domain::OAuth => "validators-oauth.gtpack",
82    };
83    let path = root.join("validators").join(domain_name(domain)).join(name);
84    if path.exists() { Some(path) } else { None }
85}
86
87pub fn ensure_cbor_packs(root: &Path) -> anyhow::Result<()> {
88    let mut roots = Vec::new();
89    let providers = root.join("providers");
90    if providers.exists() {
91        roots.push(providers);
92    }
93    let packs = root.join("packs");
94    if packs.exists() {
95        roots.push(packs);
96    }
97    for root in roots {
98        for pack in collect_gtpacks(&root)? {
99            let file = std::fs::File::open(&pack)?;
100            let mut archive = zip::ZipArchive::new(file)?;
101            let manifest = read_manifest_cbor(&mut archive, &pack).map_err(|err| {
102                anyhow::anyhow!(
103                    "failed to decode manifest.cbor in {}: {err}",
104                    pack.display()
105                )
106            })?;
107            if manifest.is_none() {
108                return Err(missing_cbor_error(&pack));
109            }
110        }
111    }
112    Ok(())
113}
114
115pub fn manifest_cbor_issue_detail(path: &Path) -> anyhow::Result<Option<String>> {
116    let file = std::fs::File::open(path)?;
117    let mut archive = zip::ZipArchive::new(file)?;
118    let mut manifest = match archive.by_name("manifest.cbor") {
119        Ok(file) => file,
120        Err(ZipError::FileNotFound) => {
121            return Ok(Some("manifest.cbor missing from archive".to_string()));
122        }
123        Err(err) => return Err(err.into()),
124    };
125    let mut bytes = Vec::new();
126    std::io::Read::read_to_end(&mut manifest, &mut bytes)?;
127    let value = match serde_cbor::from_slice::<CborValue>(&bytes) {
128        Ok(value) => value,
129        Err(err) => return Ok(Some(err.to_string())),
130    };
131    if let Some(path) = find_manifest_string_type_mismatch(&value) {
132        return Ok(Some(format!("invalid type at {path} (expected string)")));
133    }
134    Ok(None)
135}
136
137fn collect_gtpacks(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
138    let mut packs = Vec::new();
139    let mut stack = vec![root.to_path_buf()];
140    while let Some(dir) = stack.pop() {
141        for entry in std::fs::read_dir(&dir)? {
142            let entry = entry?;
143            let path = entry.path();
144            if entry.file_type()?.is_dir() {
145                stack.push(path);
146                continue;
147            }
148            if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
149                packs.push(path);
150            }
151        }
152    }
153    Ok(packs)
154}
155
156fn append_packs_from_root<F>(
157    packs: &mut Vec<ProviderPack>,
158    seen: &mut BTreeSet<PathBuf>,
159    root: &Path,
160    read_manifest: F,
161) -> anyhow::Result<()>
162where
163    F: Fn(&Path) -> anyhow::Result<PackManifest>,
164{
165    if !root.exists() {
166        return Ok(());
167    }
168    for path in collect_gtpacks(root)? {
169        append_pack(packs, seen, path, &read_manifest)?;
170    }
171    Ok(())
172}
173
174fn append_pack<F>(
175    packs: &mut Vec<ProviderPack>,
176    seen: &mut BTreeSet<PathBuf>,
177    path: PathBuf,
178    read_manifest: &F,
179) -> anyhow::Result<()>
180where
181    F: Fn(&Path) -> anyhow::Result<PackManifest>,
182{
183    if !seen.insert(path.clone()) {
184        return Ok(());
185    }
186    let file_name = path
187        .file_name()
188        .and_then(|value| value.to_str())
189        .ok_or_else(|| anyhow::anyhow!("invalid pack file name: {}", path.display()))?
190        .to_string();
191    let manifest = read_manifest(&path)?;
192    let meta = manifest
193        .meta
194        .ok_or_else(|| anyhow::anyhow!("pack manifest missing meta in {}", path.display()))?;
195    packs.push(ProviderPack {
196        pack_id: meta.pack_id,
197        file_name,
198        path,
199        entry_flows: meta.entry_flows,
200    });
201    Ok(())
202}
203
204pub fn discover_provider_packs(root: &Path, domain: Domain) -> anyhow::Result<Vec<ProviderPack>> {
205    let cfg = config(domain);
206    let providers_dir = root.join(cfg.providers_dir);
207    let packs_dir = root.join("packs");
208    let mut packs = Vec::new();
209    let mut seen = BTreeSet::new();
210    append_packs_from_root(&mut packs, &mut seen, &providers_dir, read_pack_manifest)?;
211    append_packs_from_root(&mut packs, &mut seen, &packs_dir, read_pack_manifest)?;
212    packs.sort_by(|a, b| a.file_name.cmp(&b.file_name));
213    Ok(packs)
214}
215
216pub fn discover_provider_packs_cbor_only(
217    root: &Path,
218    domain: Domain,
219) -> anyhow::Result<Vec<ProviderPack>> {
220    let cfg = config(domain);
221    let providers_dir = root.join(cfg.providers_dir);
222    let packs_dir = root.join("packs");
223    let mut packs = Vec::new();
224    let mut seen = BTreeSet::new();
225    append_packs_from_root(
226        &mut packs,
227        &mut seen,
228        &providers_dir,
229        read_pack_manifest_cbor_only,
230    )?;
231    append_packs_from_root(
232        &mut packs,
233        &mut seen,
234        &packs_dir,
235        read_pack_manifest_cbor_only,
236    )?;
237    packs.sort_by(|a, b| a.file_name.cmp(&b.file_name));
238    Ok(packs)
239}
240
241pub fn plan_runs(
242    domain: Domain,
243    action: DomainAction,
244    packs: &[ProviderPack],
245    provider_filter: Option<&str>,
246    allow_missing_setup: bool,
247) -> anyhow::Result<Vec<PlannedRun>> {
248    let cfg = config(domain);
249    let flows: Vec<&str> = match action {
250        DomainAction::Setup => vec![cfg.setup_flow],
251        DomainAction::Diagnostics => vec![cfg.diagnostics_flow],
252        DomainAction::Verify => cfg.verify_flows.to_vec(),
253    };
254
255    let mut plan = Vec::new();
256    for pack in packs {
257        if let Some(filter) = provider_filter {
258            let file_stem = pack
259                .file_name
260                .strip_suffix(".gtpack")
261                .unwrap_or(&pack.file_name);
262            let matches = pack.pack_id == filter
263                || pack.file_name == filter
264                || file_stem == filter
265                || pack.pack_id.contains(filter)
266                || pack.file_name.contains(filter)
267                || file_stem.contains(filter);
268            if !matches {
269                continue;
270            }
271        }
272
273        for flow in &flows {
274            let has_flow = pack.entry_flows.iter().any(|entry| entry == flow);
275            if !has_flow {
276                if action == DomainAction::Setup && !allow_missing_setup {
277                    return Err(anyhow::anyhow!(
278                        "Missing required flow '{}' in provider pack {}",
279                        flow,
280                        pack.file_name
281                    ));
282                }
283                eprintln!(
284                    "Warning: provider pack {} missing flow {}; skipping.",
285                    pack.file_name, flow
286                );
287                continue;
288            }
289            plan.push(PlannedRun {
290                pack: pack.clone(),
291                flow_id: (*flow).to_string(),
292            });
293        }
294    }
295    Ok(plan)
296}
297
298#[derive(Debug, Deserialize)]
299struct PackManifest {
300    #[serde(default)]
301    meta: Option<PackMeta>,
302    #[serde(default)]
303    pack_id: Option<String>,
304    #[serde(default)]
305    flows: Vec<PackFlow>,
306}
307
308#[derive(Debug, Deserialize)]
309pub(crate) struct PackMeta {
310    pub pack_id: String,
311    #[serde(default)]
312    pub entry_flows: Vec<String>,
313}
314
315#[derive(Debug, Deserialize)]
316struct PackFlow {
317    id: String,
318    #[serde(default)]
319    entrypoints: Vec<String>,
320}
321
322fn read_pack_manifest(path: &Path) -> anyhow::Result<PackManifest> {
323    let file = std::fs::File::open(path)?;
324    match zip::ZipArchive::new(file) {
325        Ok(mut archive) => {
326            let manifest = read_pack_manifest_data(&mut archive, path)
327                .with_context(|| format!("failed to read pack manifest from {}", path.display()))?;
328            let meta = build_pack_meta(&manifest, path);
329            Ok(PackManifest {
330                meta: Some(meta),
331                pack_id: None,
332                flows: Vec::new(),
333            })
334        }
335        Err(_) => read_pack_manifest_from_tar(path),
336    }
337}
338
339pub(crate) fn read_pack_meta(path: &Path) -> anyhow::Result<PackMeta> {
340    let manifest = if path.is_dir() {
341        read_pack_manifest_from_dir(path)
342    } else {
343        read_pack_manifest(path)
344    }?;
345    manifest
346        .meta
347        .ok_or_else(|| anyhow::anyhow!("pack manifest missing meta in {}", path.display()))
348}
349
350fn read_pack_manifest_cbor_only(path: &Path) -> anyhow::Result<PackManifest> {
351    let file = std::fs::File::open(path)?;
352    match zip::ZipArchive::new(file) {
353        Ok(mut archive) => {
354            let manifest = match read_manifest_cbor(&mut archive, path).map_err(|err| {
355                anyhow::anyhow!(
356                    "failed to decode manifest.cbor in {}: {err}",
357                    path.display()
358                )
359            })? {
360                Some(manifest) => manifest,
361                None => return Err(missing_cbor_error(path)),
362            };
363            let meta = build_pack_meta(&manifest, path);
364            Ok(PackManifest {
365                meta: Some(meta),
366                pack_id: None,
367                flows: Vec::new(),
368            })
369        }
370        Err(_) => read_pack_manifest_from_tar(path),
371    }
372}
373
374fn read_pack_manifest_data(
375    archive: &mut zip::ZipArchive<std::fs::File>,
376    path: &Path,
377) -> anyhow::Result<PackManifest> {
378    match read_manifest_cbor(archive, path) {
379        Ok(Some(manifest)) => return Ok(manifest),
380        Ok(None) => {}
381        Err(err) => {
382            return Err(anyhow::anyhow!(
383                "failed to decode manifest.cbor in {}: {err}",
384                path.display()
385            ));
386        }
387    }
388    match read_manifest_json(archive, "pack.manifest.json") {
389        Ok(Some(manifest)) => return Ok(manifest),
390        Ok(None) => {}
391        Err(err) => {
392            return Err(anyhow::anyhow!(
393                "failed to decode pack.manifest.json in {}: {err}",
394                path.display()
395            ));
396        }
397    }
398    Err(anyhow::anyhow!(
399        "pack manifest not found in archive {} (expected manifest.cbor or pack.manifest.json)",
400        path.display()
401    ))
402}
403
404fn read_manifest_cbor(
405    archive: &mut zip::ZipArchive<std::fs::File>,
406    _path: &Path,
407) -> anyhow::Result<Option<PackManifest>> {
408    let mut file = match archive.by_name("manifest.cbor") {
409        Ok(file) => file,
410        Err(ZipError::FileNotFound) => return Ok(None),
411        Err(err) => return Err(err.into()),
412    };
413    let mut bytes = Vec::new();
414    std::io::Read::read_to_end(&mut file, &mut bytes)?;
415    let manifest = parse_manifest_cbor_bytes(&bytes)?;
416    Ok(Some(manifest))
417}
418
419fn read_pack_manifest_from_dir(path: &Path) -> anyhow::Result<PackManifest> {
420    let manifest_path = path.join("manifest.cbor");
421    if !manifest_path.exists() {
422        return Err(anyhow::anyhow!(
423            "pack manifest missing manifest.cbor in {}",
424            path.display()
425        ));
426    }
427    let bytes = fs::read(&manifest_path)?;
428    parse_manifest_cbor_bytes(&bytes)
429}
430
431fn read_pack_manifest_from_tar(path: &Path) -> anyhow::Result<PackManifest> {
432    let bytes = read_tar_entry_bytes(path, "manifest.cbor")?;
433    let manifest = parse_manifest_cbor_bytes(&bytes)?;
434    let meta = build_pack_meta(&manifest, path);
435    Ok(PackManifest {
436        meta: Some(meta),
437        pack_id: None,
438        flows: Vec::new(),
439    })
440}
441
442fn read_tar_entry_bytes(path: &Path, entry_name: &str) -> anyhow::Result<Vec<u8>> {
443    let file = std::fs::File::open(path)?;
444    let mut archive = tar::Archive::new(file);
445    for entry in archive.entries()? {
446        let mut entry = entry?;
447        if entry.path()?.as_ref() == Path::new(entry_name) {
448            let mut bytes = Vec::new();
449            std::io::Read::read_to_end(&mut entry, &mut bytes)?;
450            return Ok(bytes);
451        }
452    }
453    Err(anyhow::anyhow!(
454        "pack manifest not found in archive {} (expected {entry_name})",
455        path.display()
456    ))
457}
458
459fn parse_manifest_cbor_bytes(bytes: &[u8]) -> anyhow::Result<PackManifest> {
460    let value: CborValue = serde_cbor::from_slice(bytes)?;
461    match decode_manifest_lenient(&value) {
462        Ok(manifest) => Ok(manifest),
463        Err(decode_err) => {
464            if let Some(err_path) = find_manifest_string_type_mismatch(&value) {
465                return Err(anyhow::anyhow!(
466                    "invalid type at {} (expected string)",
467                    err_path
468                ));
469            }
470            Err(anyhow::anyhow!(
471                "manifest.cbor uses symbol table encoding but could not be decoded: {decode_err}"
472            ))
473        }
474    }
475}
476
477fn build_pack_meta(manifest: &PackManifest, path: &Path) -> PackMeta {
478    let pack_id = manifest
479        .meta
480        .as_ref()
481        .map(|meta| meta.pack_id.clone())
482        .or_else(|| manifest.pack_id.clone())
483        .unwrap_or_else(|| {
484            let fallback = path
485                .file_stem()
486                .and_then(|value| value.to_str())
487                .unwrap_or("pack")
488                .to_string();
489            eprintln!(
490                "Warning: pack manifest missing pack id; using filename '{}' for {}",
491                fallback,
492                path.display()
493            );
494            fallback
495        });
496    let mut entry_flows = manifest
497        .meta
498        .as_ref()
499        .map(|meta| meta.entry_flows.clone())
500        .unwrap_or_default();
501    if entry_flows.is_empty() {
502        for flow in &manifest.flows {
503            entry_flows.push(flow.id.clone());
504            entry_flows.extend(flow.entrypoints.iter().cloned());
505        }
506    }
507    if entry_flows.is_empty() {
508        entry_flows.push(pack_id.clone());
509    }
510    PackMeta {
511        pack_id,
512        entry_flows,
513    }
514}
515
516fn read_manifest_json(
517    archive: &mut zip::ZipArchive<std::fs::File>,
518    name: &str,
519) -> anyhow::Result<Option<PackManifest>> {
520    let mut file = match archive.by_name(name) {
521        Ok(file) => file,
522        Err(ZipError::FileNotFound) => return Ok(None),
523        Err(err) => return Err(err.into()),
524    };
525    let mut contents = String::new();
526    std::io::Read::read_to_string(&mut file, &mut contents)?;
527    let manifest: PackManifest = serde_json::from_str(&contents)?;
528    Ok(Some(manifest))
529}
530
531fn find_manifest_string_type_mismatch(value: &CborValue) -> Option<String> {
532    let CborValue::Map(map) = value else {
533        return None;
534    };
535    let symbols = symbols_map(map);
536
537    if let Some(pack_id) = map_get(map, "pack_id")
538        && !value_is_string_or_symbol(pack_id, symbols, "pack_ids")
539    {
540        return Some("pack_id".to_string());
541    }
542
543    if let Some(meta) = map_get(map, "meta") {
544        let CborValue::Map(meta_map) = meta else {
545            return Some("meta".to_string());
546        };
547        if let Some(pack_id) = map_get(meta_map, "pack_id")
548            && !value_is_string_or_symbol(pack_id, symbols, "pack_ids")
549        {
550            return Some("meta.pack_id".to_string());
551        }
552        if let Some(entry_flows) = map_get(meta_map, "entry_flows") {
553            let CborValue::Array(values) = entry_flows else {
554                return Some("meta.entry_flows".to_string());
555            };
556            for (idx, value) in values.iter().enumerate() {
557                if !value_is_string_or_symbol(value, symbols, "flow_ids") {
558                    return Some(format!("meta.entry_flows[{idx}]"));
559                }
560            }
561        }
562    }
563
564    if let Some(flows) = map_get(map, "flows") {
565        let CborValue::Array(values) = flows else {
566            return Some("flows".to_string());
567        };
568        for (idx, value) in values.iter().enumerate() {
569            let CborValue::Map(flow) = value else {
570                return Some(format!("flows[{idx}]"));
571            };
572            if let Some(id) = map_get(flow, "id")
573                && !value_is_string_or_symbol(id, symbols, "flow_ids")
574            {
575                return Some(format!("flows[{idx}].id"));
576            }
577            if let Some(entrypoints) = map_get(flow, "entrypoints") {
578                let CborValue::Array(values) = entrypoints else {
579                    return Some(format!("flows[{idx}].entrypoints"));
580                };
581                for (jdx, value) in values.iter().enumerate() {
582                    if !value_is_string_or_symbol(value, symbols, "entrypoints") {
583                        return Some(format!("flows[{idx}].entrypoints[{jdx}]"));
584                    }
585                }
586            }
587        }
588    }
589
590    None
591}
592
593fn map_get<'a>(
594    map: &'a std::collections::BTreeMap<CborValue, CborValue>,
595    key: &str,
596) -> Option<&'a CborValue> {
597    map.iter().find_map(|(k, v)| match k {
598        CborValue::Text(text) if text == key => Some(v),
599        _ => None,
600    })
601}
602
603fn symbols_map(
604    map: &std::collections::BTreeMap<CborValue, CborValue>,
605) -> Option<&std::collections::BTreeMap<CborValue, CborValue>> {
606    let symbols = map_get(map, "symbols")?;
607    match symbols {
608        CborValue::Map(map) => Some(map),
609        _ => None,
610    }
611}
612
613fn value_is_string_or_symbol(
614    value: &CborValue,
615    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
616    symbol_key: &str,
617) -> bool {
618    if matches!(value, CborValue::Text(_)) {
619        return true;
620    }
621    let CborValue::Integer(idx) = value else {
622        return false;
623    };
624    let symbols = match symbols {
625        Some(symbols) => symbols,
626        None => return true,
627    };
628    let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
629        .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
630    else {
631        return true;
632    };
633    let idx = match usize::try_from(*idx) {
634        Ok(idx) => idx,
635        Err(_) => return true,
636    };
637    matches!(values.get(idx), Some(CborValue::Text(_)))
638}
639
640fn decode_manifest_lenient(value: &CborValue) -> anyhow::Result<PackManifest> {
641    let CborValue::Map(map) = value else {
642        return Err(anyhow::anyhow!("manifest is not a map"));
643    };
644    let symbols = symbols_map(map);
645
646    let (meta_pack_id, meta_entry_flows) = if let Some(meta) = map_get(map, "meta") {
647        let CborValue::Map(meta_map) = meta else {
648            return Err(anyhow::anyhow!("meta is not a map"));
649        };
650        let pack_id = resolve_string_symbol(map_get(meta_map, "pack_id"), symbols, "pack_ids")?;
651        let entry_flows = resolve_string_array(
652            map_get(meta_map, "entry_flows"),
653            symbols,
654            "flow_ids",
655            Some("entrypoints"),
656        )?;
657        (pack_id, entry_flows)
658    } else {
659        (None, Vec::new())
660    };
661
662    let pack_id = resolve_string_symbol(map_get(map, "pack_id"), symbols, "pack_ids")?
663        .or(meta_pack_id)
664        .ok_or_else(|| anyhow::anyhow!("pack_id missing"))?;
665
666    let mut flows = Vec::new();
667    if let Some(flows_value) = map_get(map, "flows") {
668        let CborValue::Array(values) = flows_value else {
669            return Err(anyhow::anyhow!("flows is not an array"));
670        };
671        for (idx, value) in values.iter().enumerate() {
672            let CborValue::Map(flow) = value else {
673                return Err(anyhow::anyhow!("flows[{idx}] is not a map"));
674            };
675            let id = resolve_string_symbol(map_get(flow, "id"), symbols, "flow_ids")?
676                .ok_or_else(|| anyhow::anyhow!("flows[{idx}].id missing"))?;
677            let entrypoints =
678                resolve_string_array(map_get(flow, "entrypoints"), symbols, "entrypoints", None)?;
679            flows.push(PackFlow { id, entrypoints });
680        }
681    }
682
683    Ok(PackManifest {
684        meta: Some(PackMeta {
685            pack_id,
686            entry_flows: meta_entry_flows,
687        }),
688        pack_id: None,
689        flows,
690    })
691}
692
693fn resolve_string_symbol(
694    value: Option<&CborValue>,
695    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
696    symbol_key: &str,
697) -> anyhow::Result<Option<String>> {
698    let Some(value) = value else {
699        return Ok(None);
700    };
701    match value {
702        CborValue::Text(text) => Ok(Some(text.clone())),
703        CborValue::Integer(idx) => {
704            let Some(symbols) = symbols else {
705                return Ok(Some(idx.to_string()));
706            };
707            let Some(values) = symbol_array(symbols, symbol_key) else {
708                return Ok(Some(idx.to_string()));
709            };
710            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
711            match values.get(idx) {
712                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
713                _ => Ok(Some(idx.to_string())),
714            }
715        }
716        _ => Err(anyhow::anyhow!("expected string or symbol index")),
717    }
718}
719
720fn symbol_array<'a>(
721    symbols: &'a std::collections::BTreeMap<CborValue, CborValue>,
722    key: &'a str,
723) -> Option<&'a Vec<CborValue>> {
724    if let Some(CborValue::Array(values)) = map_get(symbols, key) {
725        return Some(values);
726    }
727    if let Some(stripped) = key.strip_suffix('s')
728        && let Some(CborValue::Array(values)) = map_get(symbols, stripped)
729    {
730        return Some(values);
731    }
732    None
733}
734
735fn resolve_string_array(
736    value: Option<&CborValue>,
737    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
738    symbol_key: &str,
739    fallback_key: Option<&str>,
740) -> anyhow::Result<Vec<String>> {
741    let Some(value) = value else {
742        return Ok(Vec::new());
743    };
744    let CborValue::Array(values) = value else {
745        return Err(anyhow::anyhow!("expected array"));
746    };
747    let mut out = Vec::new();
748    for (idx, value) in values.iter().enumerate() {
749        match resolve_string_symbol(Some(value), symbols, symbol_key) {
750            Ok(Some(value)) => out.push(value),
751            Ok(None) => {}
752            Err(err) => {
753                if let Some(fallback_key) = fallback_key
754                    && let Ok(Some(value)) =
755                        resolve_string_symbol(Some(value), symbols, fallback_key)
756                {
757                    out.push(value);
758                    continue;
759                }
760                return Err(anyhow::anyhow!("{err} at index {idx}"));
761            }
762        }
763    }
764    Ok(out)
765}
766
767fn missing_cbor_error(path: &Path) -> anyhow::Error {
768    anyhow::anyhow!(
769        "ERROR: demo packs must be CBOR-only (.gtpack must contain manifest.cbor). Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
770        path.display()
771    )
772}
773
774pub(crate) fn domain_name(domain: Domain) -> &'static str {
775    match domain {
776        Domain::Messaging => "messaging",
777        Domain::Events => "events",
778        Domain::Secrets => "secrets",
779        Domain::OAuth => "oauth",
780    }
781}