Skip to main content

greentic_setup/
bundle.rs

1//! Bundle directory structure creation and management.
2//!
3//! Handles creating the demo bundle scaffold, writing configuration files,
4//! and managing tenant/team directories.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, anyhow};
9use serde_json::{Map as JsonMap, Value as JsonValue};
10use serde_yaml_bw::{Mapping as YamlMapping, Sequence as YamlSequence, Value as YamlValue};
11
12pub const LEGACY_BUNDLE_MARKER: &str = "greentic.demo.yaml";
13pub const BUNDLE_WORKSPACE_MARKER: &str = "bundle.yaml";
14pub const BUNDLE_LOCK_FILE: &str = "bundle.lock.json";
15
16/// The bundle metadata list a pack reference should be written into.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum BundleReferenceKind {
19    AppPack,
20    ExtensionProvider,
21}
22
23/// One bundle dependency entry to register in `bundle.yaml` and `bundle.lock.json`.
24#[derive(Clone, Debug)]
25pub struct BundleReference {
26    pub kind: BundleReferenceKind,
27    pub reference: String,
28    pub digest: Option<String>,
29}
30
31/// Create the standard demo bundle directory structure.
32pub fn create_demo_bundle_structure(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<()> {
33    let directories = [
34        "",
35        "providers",
36        "providers/messaging",
37        "providers/events",
38        "providers/secrets",
39        "providers/oauth",
40        "packs",
41        "resolved",
42        "state",
43        "state/resolved",
44        "state/runs",
45        "state/pids",
46        "state/logs",
47        "state/runtime",
48        "state/doctor",
49        "tenants",
50        "tenants/default",
51        "tenants/default/teams",
52        "tenants/demo",
53        "tenants/demo/teams",
54        "tenants/demo/teams/default",
55        "logs",
56    ];
57    for directory in directories {
58        std::fs::create_dir_all(root.join(directory))?;
59    }
60
61    let mut demo_yaml = "version: \"1\"\nproject_root: \"./\"\n".to_string();
62    if let Some(name) = bundle_name.filter(|v| !v.trim().is_empty()) {
63        demo_yaml.push_str(&format!("bundle_name: \"{}\"\n", name.replace('"', "")));
64    }
65    write_if_missing(&root.join(LEGACY_BUNDLE_MARKER), &demo_yaml)?;
66    write_if_missing(
67        &root.join("tenants").join("default").join("tenant.gmap"),
68        "_ = forbidden\n",
69    )?;
70    write_if_missing(
71        &root.join("tenants").join("demo").join("tenant.gmap"),
72        "_ = forbidden\n",
73    )?;
74    write_if_missing(
75        &root
76            .join("tenants")
77            .join("demo")
78            .join("teams")
79            .join("default")
80            .join("team.gmap"),
81        "_ = forbidden\n",
82    )?;
83
84    // Write embedded welcome default.gtpack only when the bundle does not already
85    // declare its own app packs (e.g. via wizard --answers).  When app_packs is
86    // present in bundle.yaml the user has an explicit pack reference and the
87    // generic welcome pack would just shadow it.
88    if !bundle_has_app_packs(root) {
89        write_default_pack_if_missing(root);
90    }
91
92    ensure_bundle_metadata(root, bundle_name)?;
93
94    Ok(())
95}
96
97/// Return `true` when `bundle.yaml` already declares at least one app pack.
98fn bundle_has_app_packs(bundle_root: &Path) -> bool {
99    let workspace = bundle_root.join(BUNDLE_WORKSPACE_MARKER);
100    let Ok(contents) = std::fs::read_to_string(&workspace) else {
101        return false;
102    };
103    let Ok(workspace) = serde_yaml_bw::from_str::<YamlValue>(&contents) else {
104        return false;
105    };
106    let Some(workspace_map) = workspace.as_mapping() else {
107        return false;
108    };
109    !yaml_string_list(workspace_map, "app_packs").is_empty()
110}
111
112/// Embedded quickstart pack bytes (built from `assets/default-welcome.gtpack`).
113///
114/// This pack contains an Adaptive Card menu flow (quickstart demo) using the
115/// adaptive-card component with text + button routing, i18n support, and
116/// Handlebars template rendering for dynamic card content.
117const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
118
119/// Write the embedded welcome pack as `packs/default.gtpack` if not already present.
120fn write_default_pack_if_missing(bundle_root: &Path) {
121    let target = bundle_root.join("packs").join("default.gtpack");
122    if target.exists() {
123        return;
124    }
125    if let Err(err) = std::fs::write(&target, EMBEDDED_WELCOME_PACK) {
126        eprintln!(
127            "  [scaffold] WARNING: failed to write default.gtpack: {}",
128            err,
129        );
130    } else {
131        println!("  [scaffold] created default.gtpack (welcome flow)");
132    }
133}
134
135/// Write a file only if it doesn't already exist.
136pub fn write_if_missing(path: &Path, contents: &str) -> anyhow::Result<()> {
137    if path.exists() {
138        return Ok(());
139    }
140    if let Some(parent) = path.parent() {
141        std::fs::create_dir_all(parent)?;
142    }
143    std::fs::write(path, contents)?;
144    Ok(())
145}
146
147/// Validate that a bundle directory exists and has the expected marker file.
148pub fn validate_bundle_exists(bundle: &Path) -> anyhow::Result<()> {
149    if !bundle.exists() {
150        return Err(anyhow!("bundle path {} does not exist", bundle.display()));
151    }
152    if !is_bundle_root(bundle) {
153        return Err(anyhow!(
154            "bundle {} missing {} or {}",
155            bundle.display(),
156            LEGACY_BUNDLE_MARKER,
157            BUNDLE_WORKSPACE_MARKER,
158        ));
159    }
160    Ok(())
161}
162
163pub fn is_bundle_root(bundle: &Path) -> bool {
164    bundle.join(LEGACY_BUNDLE_MARKER).exists() || bundle.join(BUNDLE_WORKSPACE_MARKER).exists()
165}
166
167/// Ensure normalized bundle metadata files exist.
168pub fn ensure_bundle_metadata(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<()> {
169    let workspace = load_bundle_workspace_doc(root, bundle_name)?;
170    write_bundle_workspace_doc(root, &workspace)?;
171    sync_bundle_lock_with_workspace(root, &workspace, &[])?;
172    Ok(())
173}
174
175/// Register pack references in both `bundle.yaml` and `bundle.lock.json`.
176pub fn register_bundle_references(
177    root: &Path,
178    refs: &[BundleReference],
179    bundle_name: Option<&str>,
180) -> anyhow::Result<()> {
181    let mut workspace = load_bundle_workspace_doc(root, bundle_name)?;
182    {
183        let map = yaml_object_mut(&mut workspace)?;
184        let mut app_packs = yaml_string_list(map, "app_packs");
185        let mut extension_providers = yaml_string_list(map, "extension_providers");
186
187        for entry in refs {
188            match entry.kind {
189                BundleReferenceKind::AppPack => app_packs.push(entry.reference.clone()),
190                BundleReferenceKind::ExtensionProvider => {
191                    extension_providers.push(entry.reference.clone())
192                }
193            }
194        }
195
196        sort_unique_strings(&mut app_packs);
197        sort_unique_strings(&mut extension_providers);
198        yaml_set_string_list(map, "app_packs", &app_packs);
199        yaml_set_string_list(map, "extension_providers", &extension_providers);
200    }
201
202    prune_scaffold_default_pack(root, &workspace)?;
203    write_bundle_workspace_doc(root, &workspace)?;
204    sync_bundle_lock_with_workspace(root, &workspace, refs)?;
205    Ok(())
206}
207
208/// Compute the gmap file path for a tenant/team in a bundle.
209pub fn gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
210    let mut path = bundle.join("tenants").join(tenant);
211    if let Some(team) = team {
212        path = path.join("teams").join(team).join("team.gmap");
213    } else {
214        path = path.join("tenant.gmap");
215    }
216    path
217}
218
219/// Compute the resolved manifest filename for a tenant/team.
220pub fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
221    match team {
222        Some(team) => format!("{tenant}.{team}.yaml"),
223        None => format!("{tenant}.yaml"),
224    }
225}
226
227/// Locate a provider's `.gtpack` file in the bundle by provider_id stem.
228pub fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<PathBuf> {
229    for subdir in &["providers/messaging", "providers/events", "packs"] {
230        let candidate = bundle.join(subdir).join(format!("{provider_id}.gtpack"));
231        if candidate.exists() {
232            return Some(candidate);
233        }
234    }
235    None
236}
237
238/// Discover tenants inside the bundle.
239///
240/// Scans `{bundle}/tenants/` for subdirectories and files, returning
241/// tenant names (directory names or file stems without extension).
242///
243/// If `domain` is provided, first checks `{bundle}/{domain}/tenants/`
244/// and falls back to the general `{bundle}/tenants/` directory.
245pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
246    // Try domain-specific tenants directory first
247    if let Some(domain_name) = domain {
248        let domain_dir = bundle.join(domain_name).join("tenants");
249        if let Some(tenants) = read_tenants_from_dir(&domain_dir)? {
250            return Ok(tenants);
251        }
252    }
253
254    // Fall back to general tenants directory
255    let general_dir = bundle.join("tenants");
256    if let Some(tenants) = read_tenants_from_dir(&general_dir)? {
257        return Ok(tenants);
258    }
259
260    Ok(Vec::new())
261}
262
263/// Read tenant names from a directory.
264fn read_tenants_from_dir(dir: &Path) -> anyhow::Result<Option<Vec<String>>> {
265    use std::collections::BTreeSet;
266
267    if !dir.exists() {
268        return Ok(None);
269    }
270
271    let mut tenants = BTreeSet::new();
272    for entry in std::fs::read_dir(dir)? {
273        let entry = entry?;
274        let path = entry.path();
275
276        if path.is_dir() {
277            if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
278                tenants.insert(name.to_string());
279            }
280            continue;
281        }
282
283        if path.is_file()
284            && let Some(stem) = path.file_stem().and_then(|v| v.to_str())
285        {
286            tenants.insert(stem.to_string());
287        }
288    }
289
290    Ok(Some(tenants.into_iter().collect()))
291}
292
293/// Read and parse the provider registry JSON from a bundle.
294pub fn load_provider_registry(bundle: &Path) -> anyhow::Result<serde_json::Value> {
295    let path = bundle.join("providers").join("providers.json");
296    if path.exists() {
297        let raw = std::fs::read_to_string(&path)
298            .with_context(|| format!("read provider registry {}", path.display()))?;
299        serde_json::from_str(&raw)
300            .with_context(|| format!("parse provider registry {}", path.display()))
301    } else {
302        Ok(serde_json::json!({ "providers": [] }))
303    }
304}
305
306/// Write the provider registry JSON to a bundle.
307pub fn write_provider_registry(bundle: &Path, root: &serde_json::Value) -> anyhow::Result<()> {
308    let path = bundle.join("providers").join("providers.json");
309    if let Some(parent) = path.parent() {
310        std::fs::create_dir_all(parent)?;
311    }
312    let payload = serde_json::to_string_pretty(root)
313        .with_context(|| format!("serialize provider registry {}", path.display()))?;
314    std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
315}
316
317fn load_bundle_workspace_doc(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<YamlValue> {
318    let path = root.join(BUNDLE_WORKSPACE_MARKER);
319    let mut doc = if path.exists() {
320        let raw =
321            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
322        serde_yaml_bw::from_str::<YamlValue>(&raw)
323            .with_context(|| format!("parse {}", path.display()))?
324    } else {
325        YamlValue::Mapping(YamlMapping::new())
326    };
327
328    let bundle_id = infer_bundle_id(root);
329    let bundle_name = bundle_name
330        .filter(|value| !value.trim().is_empty())
331        .map(ToOwned::to_owned)
332        .unwrap_or_else(|| infer_bundle_name(root));
333
334    let map = yaml_object_mut(&mut doc)?;
335    yaml_set_default(map, "schema_version", YamlValue::Number(1.into(), None));
336    yaml_set_default(map, "bundle_id", yaml_string(bundle_id.clone()));
337    yaml_set_default(map, "bundle_name", yaml_string(bundle_name));
338    yaml_set_default(map, "locale", yaml_string("en"));
339    yaml_set_default(map, "mode", yaml_string("create"));
340    yaml_set_default(map, "advanced_setup", YamlValue::Bool(false, None));
341    yaml_set_default(map, "app_packs", YamlValue::Sequence(YamlSequence::new()));
342    yaml_set_default(
343        map,
344        "app_pack_mappings",
345        YamlValue::Sequence(YamlSequence::new()),
346    );
347    yaml_set_default(
348        map,
349        "extension_providers",
350        YamlValue::Sequence(YamlSequence::new()),
351    );
352    yaml_set_default(
353        map,
354        "remote_catalogs",
355        YamlValue::Sequence(YamlSequence::new()),
356    );
357    yaml_set_default(map, "hooks", YamlValue::Sequence(YamlSequence::new()));
358    yaml_set_default(
359        map,
360        "subscriptions",
361        YamlValue::Sequence(YamlSequence::new()),
362    );
363    yaml_set_default(
364        map,
365        "capabilities",
366        YamlValue::Sequence(YamlSequence::new()),
367    );
368    yaml_set_default(map, "setup_execution_intent", YamlValue::Bool(false, None));
369    yaml_set_default(map, "export_intent", YamlValue::Bool(false, None));
370    Ok(doc)
371}
372
373fn write_bundle_workspace_doc(root: &Path, doc: &YamlValue) -> anyhow::Result<()> {
374    let path = root.join(BUNDLE_WORKSPACE_MARKER);
375    if let Some(parent) = path.parent() {
376        std::fs::create_dir_all(parent)?;
377    }
378    let mut rendered =
379        serde_yaml_bw::to_string(doc).with_context(|| format!("serialize {}", path.display()))?;
380    if let Some(stripped) = rendered.strip_prefix("---\n") {
381        rendered = stripped.to_string();
382    }
383    if !rendered.ends_with('\n') {
384        rendered.push('\n');
385    }
386    std::fs::write(&path, rendered).with_context(|| format!("write {}", path.display()))
387}
388
389fn sync_bundle_lock_with_workspace(
390    root: &Path,
391    workspace: &YamlValue,
392    updated_refs: &[BundleReference],
393) -> anyhow::Result<()> {
394    let path = root.join(BUNDLE_LOCK_FILE);
395    let mut doc = if path.exists() {
396        let raw =
397            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
398        serde_json::from_str::<JsonValue>(&raw)
399            .with_context(|| format!("parse {}", path.display()))?
400    } else {
401        JsonValue::Object(JsonMap::new())
402    };
403
404    let workspace_map = workspace
405        .as_mapping()
406        .ok_or_else(|| anyhow!("bundle workspace must be a YAML object"))?;
407    let bundle_id =
408        yaml_get_string(workspace_map, "bundle_id").unwrap_or_else(|| infer_bundle_id(root));
409    let mode = yaml_get_string(workspace_map, "mode").unwrap_or_else(|| "create".to_string());
410    let app_packs = yaml_string_list(workspace_map, "app_packs");
411    let extension_providers = yaml_string_list(workspace_map, "extension_providers");
412
413    let obj = json_object_mut(&mut doc)?;
414    json_set_default(obj, "schema_version", JsonValue::from(1));
415    json_set_default(obj, "bundle_id", JsonValue::String(bundle_id));
416    json_set_default(obj, "requested_mode", JsonValue::String(mode));
417    json_set_default(obj, "execution", JsonValue::String("execute".to_string()));
418    json_set_default(
419        obj,
420        "cache_policy",
421        JsonValue::String("workspace-local".to_string()),
422    );
423    obj.insert(
424        "tool_version".to_string(),
425        JsonValue::String(env!("CARGO_PKG_VERSION").to_string()),
426    );
427    json_set_default(
428        obj,
429        "build_format_version",
430        JsonValue::String("bundle-lock-v1".to_string()),
431    );
432    obj.insert(
433        "workspace_root".to_string(),
434        JsonValue::String(BUNDLE_WORKSPACE_MARKER.to_string()),
435    );
436    obj.insert(
437        "lock_file".to_string(),
438        JsonValue::String(BUNDLE_LOCK_FILE.to_string()),
439    );
440    json_set_default(obj, "catalogs", JsonValue::Array(Vec::new()));
441    json_set_default(obj, "setup_state_files", JsonValue::Array(Vec::new()));
442
443    let digests_by_ref: std::collections::BTreeMap<String, Option<String>> = updated_refs
444        .iter()
445        .map(|entry| (entry.reference.clone(), entry.digest.clone()))
446        .collect();
447    json_set_dependency_locks(obj, "app_packs", &app_packs, &digests_by_ref);
448    json_set_dependency_locks(
449        obj,
450        "extension_providers",
451        &extension_providers,
452        &digests_by_ref,
453    );
454
455    let payload = serde_json::to_string_pretty(&doc)
456        .with_context(|| format!("serialize {}", path.display()))?;
457    std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
458}
459
460fn prune_scaffold_default_pack(root: &Path, workspace: &YamlValue) -> anyhow::Result<()> {
461    let Some(workspace_map) = workspace.as_mapping() else {
462        return Ok(());
463    };
464    let app_packs = yaml_string_list(workspace_map, "app_packs");
465    let has_explicit_non_default = app_packs
466        .iter()
467        .any(|entry| !entry.ends_with("default.gtpack"));
468    if !has_explicit_non_default {
469        return Ok(());
470    }
471
472    let default_pack = root.join("packs").join("default.gtpack");
473    if !default_pack.exists() {
474        return Ok(());
475    }
476
477    let contents =
478        std::fs::read(&default_pack).with_context(|| format!("read {}", default_pack.display()))?;
479    if contents == EMBEDDED_WELCOME_PACK {
480        std::fs::remove_file(&default_pack)
481            .with_context(|| format!("remove {}", default_pack.display()))?;
482    }
483    Ok(())
484}
485
486fn infer_bundle_id(root: &Path) -> String {
487    root.file_name()
488        .and_then(|value| value.to_str())
489        .map(ToOwned::to_owned)
490        .filter(|value| !value.trim().is_empty())
491        .unwrap_or_else(|| "bundle".to_string())
492}
493
494fn infer_bundle_name(root: &Path) -> String {
495    infer_bundle_id(root)
496}
497
498fn yaml_object_mut(value: &mut YamlValue) -> anyhow::Result<&mut YamlMapping> {
499    if !matches!(value, YamlValue::Mapping(_)) {
500        *value = YamlValue::Mapping(YamlMapping::new());
501    }
502    match value {
503        YamlValue::Mapping(map) => Ok(map),
504        _ => unreachable!(),
505    }
506}
507
508fn yaml_set_default(map: &mut YamlMapping, key: &str, value: YamlValue) {
509    let key_value = yaml_string(key);
510    if !map.contains_key(&key_value) {
511        map.insert(key_value, value);
512    }
513}
514
515fn yaml_get_string(map: &YamlMapping, key: &str) -> Option<String> {
516    map.get(yaml_string(key))
517        .and_then(YamlValue::as_str)
518        .map(ToOwned::to_owned)
519}
520
521fn yaml_string_list(map: &YamlMapping, key: &str) -> Vec<String> {
522    map.get(yaml_string(key))
523        .and_then(YamlValue::as_sequence)
524        .map(|values| {
525            values
526                .iter()
527                .filter_map(YamlValue::as_str)
528                .map(ToOwned::to_owned)
529                .collect()
530        })
531        .unwrap_or_default()
532}
533
534fn yaml_set_string_list(map: &mut YamlMapping, key: &str, values: &[String]) {
535    let mut sequence = YamlSequence::new();
536    for value in values {
537        sequence.push(yaml_string(value.clone()));
538    }
539    map.insert(yaml_string(key), YamlValue::Sequence(sequence));
540}
541
542fn yaml_string(value: impl Into<String>) -> YamlValue {
543    YamlValue::String(value.into(), None)
544}
545
546fn sort_unique_strings(values: &mut Vec<String>) {
547    values.retain(|value| !value.trim().is_empty());
548    values.sort();
549    values.dedup();
550}
551
552fn json_object_mut(value: &mut JsonValue) -> anyhow::Result<&mut JsonMap<String, JsonValue>> {
553    if !matches!(value, JsonValue::Object(_)) {
554        *value = JsonValue::Object(JsonMap::new());
555    }
556    match value {
557        JsonValue::Object(map) => Ok(map),
558        _ => unreachable!(),
559    }
560}
561
562fn json_set_default(map: &mut JsonMap<String, JsonValue>, key: &str, value: JsonValue) {
563    map.entry(key.to_string()).or_insert(value);
564}
565
566fn json_set_dependency_locks(
567    map: &mut JsonMap<String, JsonValue>,
568    key: &str,
569    references: &[String],
570    updated_digests: &std::collections::BTreeMap<String, Option<String>>,
571) {
572    let existing_digests: std::collections::BTreeMap<String, Option<String>> = map
573        .get(key)
574        .and_then(JsonValue::as_array)
575        .map(|entries| {
576            entries
577                .iter()
578                .filter_map(|entry| {
579                    let obj = entry.as_object()?;
580                    let reference = obj.get("reference")?.as_str()?.to_string();
581                    let digest = obj
582                        .get("digest")
583                        .and_then(JsonValue::as_str)
584                        .map(ToOwned::to_owned);
585                    Some((reference, digest))
586                })
587                .collect()
588        })
589        .unwrap_or_default();
590
591    let entries = references
592        .iter()
593        .map(|reference| {
594            let digest = updated_digests
595                .get(reference)
596                .cloned()
597                .unwrap_or_else(|| existing_digests.get(reference).cloned().unwrap_or(None));
598            serde_json::json!({
599                "reference": reference,
600                "digest": digest,
601            })
602        })
603        .collect::<Vec<_>>();
604    map.insert(key.to_string(), JsonValue::Array(entries));
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::engine::execute_add_packs_to_bundle;
611    use crate::plan::ResolvedPackInfo;
612    use std::io::Write;
613    use zip::write::{FileOptions, ZipWriter};
614
615    fn write_pack(path: &Path, pack_id: &str) {
616        let file = std::fs::File::create(path).unwrap();
617        let mut writer = ZipWriter::new(file);
618        let options: FileOptions<'_, ()> =
619            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
620        writer.start_file("pack.manifest.json", options).unwrap();
621        writer
622            .write_all(
623                serde_json::json!({
624                    "pack_id": pack_id,
625                    "display_name": pack_id,
626                })
627                .to_string()
628                .as_bytes(),
629            )
630            .unwrap();
631        writer.finish().unwrap();
632    }
633
634    #[test]
635    fn create_bundle_structure() {
636        let temp = tempfile::tempdir().unwrap();
637        let root = temp.path().join("demo-bundle");
638        create_demo_bundle_structure(&root, Some("test")).unwrap();
639        assert!(root.join(LEGACY_BUNDLE_MARKER).exists());
640        assert!(root.join("providers/messaging").exists());
641        assert!(root.join("tenants/demo/teams/default/team.gmap").exists());
642    }
643
644    #[test]
645    fn embedded_welcome_pack_written_when_no_sibling() {
646        let temp = tempfile::tempdir().unwrap();
647        let root = temp.path().join("new-bundle");
648        create_demo_bundle_structure(&root, Some("test")).unwrap();
649        let pack = root.join("packs").join("default.gtpack");
650        assert!(pack.exists(), "embedded welcome pack should be written");
651        assert!(
652            pack.metadata().unwrap().len() > 1000,
653            "pack should not be empty"
654        );
655    }
656
657    #[test]
658    fn embedded_welcome_pack_not_overwritten() {
659        let temp = tempfile::tempdir().unwrap();
660        let root = temp.path().join("existing-bundle");
661        std::fs::create_dir_all(root.join("packs")).unwrap();
662        std::fs::write(root.join("packs").join("default.gtpack"), b"custom").unwrap();
663        create_demo_bundle_structure(&root, Some("test")).unwrap();
664        let contents = std::fs::read(root.join("packs").join("default.gtpack")).unwrap();
665        assert_eq!(
666            contents, b"custom",
667            "existing pack should not be overwritten"
668        );
669    }
670
671    #[test]
672    fn default_pack_skipped_when_bundle_has_app_packs() {
673        let temp = tempfile::tempdir().unwrap();
674        let root = temp.path().join("custom-bundle");
675        std::fs::create_dir_all(root.join("packs")).unwrap();
676        // Write a bundle.yaml that declares an app pack
677        std::fs::write(
678            root.join(BUNDLE_WORKSPACE_MARKER),
679            "schema_version: 1\napp_packs:\n  - packs/my-flow.pack\n",
680        )
681        .unwrap();
682        create_demo_bundle_structure(&root, Some("test")).unwrap();
683        assert!(
684            !root.join("packs").join("default.gtpack").exists(),
685            "default.gtpack should NOT be created when app_packs are declared"
686        );
687    }
688
689    #[test]
690    fn default_pack_skipped_when_bundle_has_external_app_pack_ref() {
691        let temp = tempfile::tempdir().unwrap();
692        let root = temp.path().join("external-ref-bundle");
693        std::fs::create_dir_all(root.join("packs")).unwrap();
694        std::fs::write(
695            root.join(BUNDLE_WORKSPACE_MARKER),
696            "schema_version: 1\napp_packs:\n  - demos/deep-research-demo.gtpack\n",
697        )
698        .unwrap();
699
700        create_demo_bundle_structure(&root, Some("test")).unwrap();
701
702        assert!(
703            !root.join("packs").join("default.gtpack").exists(),
704            "default.gtpack should NOT be created when bundle.yaml declares an external app pack ref"
705        );
706    }
707
708    #[test]
709    fn validate_bundle_exists_fails_for_missing() {
710        let result = validate_bundle_exists(Path::new("/nonexistent"));
711        assert!(result.is_err());
712    }
713
714    #[test]
715    fn validate_bundle_exists_accepts_bundle_yaml_workspace() {
716        let temp = tempfile::tempdir().unwrap();
717        let root = temp.path().join("bundle-workspace");
718        std::fs::create_dir_all(&root).unwrap();
719        std::fs::write(root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n").unwrap();
720
721        validate_bundle_exists(&root).unwrap();
722        assert!(is_bundle_root(&root));
723    }
724
725    #[test]
726    fn add_packs_updates_bundle_workspace_and_lock() {
727        let temp = tempfile::tempdir().unwrap();
728        let root = temp.path().join("bundle-workspace");
729        create_demo_bundle_structure(&root, Some("weather-demo")).unwrap();
730
731        let source_dir = temp.path().join("src-packs");
732        std::fs::create_dir_all(&source_dir).unwrap();
733        let app_pack = source_dir.join("weather-app.gtpack");
734        let provider_pack = source_dir.join("messaging-telegram.gtpack");
735        write_pack(&app_pack, "weather-app");
736        write_pack(&provider_pack, "messaging-telegram");
737
738        execute_add_packs_to_bundle(
739            &root,
740            &[
741                ResolvedPackInfo {
742                    source_ref: app_pack.display().to_string(),
743                    mapped_ref: app_pack.display().to_string(),
744                    resolved_digest: "sha256:app".to_string(),
745                    pack_id: "weather-app".to_string(),
746                    entry_flows: Vec::new(),
747                    cached_path: app_pack.clone(),
748                    output_path: app_pack.clone(),
749                },
750                ResolvedPackInfo {
751                    source_ref: provider_pack.display().to_string(),
752                    mapped_ref: provider_pack.display().to_string(),
753                    resolved_digest: "sha256:provider".to_string(),
754                    pack_id: "messaging-telegram".to_string(),
755                    entry_flows: Vec::new(),
756                    cached_path: provider_pack.clone(),
757                    output_path: provider_pack.clone(),
758                },
759            ],
760        )
761        .unwrap();
762
763        let bundle_yaml = std::fs::read_to_string(root.join(BUNDLE_WORKSPACE_MARKER)).unwrap();
764        assert!(bundle_yaml.contains("app_packs:"));
765        assert!(bundle_yaml.contains("packs/weather-app.gtpack"));
766        assert!(bundle_yaml.contains("extension_providers:"));
767        assert!(bundle_yaml.contains("providers/messaging/messaging-telegram.gtpack"));
768
769        let lock: serde_json::Value =
770            serde_json::from_str(&std::fs::read_to_string(root.join(BUNDLE_LOCK_FILE)).unwrap())
771                .unwrap();
772        assert_eq!(
773            lock.pointer("/app_packs/0/reference")
774                .and_then(serde_json::Value::as_str),
775            Some("packs/weather-app.gtpack")
776        );
777        assert_eq!(
778            lock.pointer("/app_packs/0/digest")
779                .and_then(serde_json::Value::as_str),
780            Some("sha256:app")
781        );
782        assert_eq!(
783            lock.pointer("/extension_providers/0/reference")
784                .and_then(serde_json::Value::as_str),
785            Some("providers/messaging/messaging-telegram.gtpack")
786        );
787        assert_eq!(
788            lock.pointer("/extension_providers/0/digest")
789                .and_then(serde_json::Value::as_str),
790            Some("sha256:provider")
791        );
792        assert!(
793            !root.join("packs").join("default.gtpack").exists(),
794            "scaffold welcome pack should be removed once an explicit app pack is added"
795        );
796    }
797
798    #[test]
799    fn gmap_paths() {
800        let p = gmap_path(Path::new("/b"), "demo", None);
801        assert_eq!(p, PathBuf::from("/b/tenants/demo/tenant.gmap"));
802
803        let p = gmap_path(Path::new("/b"), "demo", Some("ops"));
804        assert_eq!(p, PathBuf::from("/b/tenants/demo/teams/ops/team.gmap"));
805    }
806
807    #[test]
808    fn resolved_manifest_filenames() {
809        assert_eq!(resolved_manifest_filename("demo", None), "demo.yaml");
810        assert_eq!(
811            resolved_manifest_filename("demo", Some("ops")),
812            "demo.ops.yaml"
813        );
814    }
815
816    #[test]
817    fn discover_tenants_reads_dirs_and_files() {
818        let bundle = tempfile::tempdir().unwrap();
819        let tenants_dir = bundle.path().join("tenants");
820        std::fs::create_dir_all(tenants_dir.join("alpha")).unwrap();
821        std::fs::write(tenants_dir.join("beta.json"), "{}").unwrap();
822
823        let tenants = discover_tenants(bundle.path(), None).unwrap();
824        assert!(tenants.contains(&"alpha".to_string()));
825        assert!(tenants.contains(&"beta".to_string()));
826    }
827
828    #[test]
829    fn discover_tenants_domain_specific() {
830        let bundle = tempfile::tempdir().unwrap();
831        let domain_dir = bundle.path().join("messaging").join("tenants");
832        std::fs::create_dir_all(domain_dir.join("gamma")).unwrap();
833
834        let tenants = discover_tenants(bundle.path(), Some("messaging")).unwrap();
835        assert_eq!(tenants, vec!["gamma".to_string()]);
836    }
837
838    #[test]
839    fn discover_tenants_falls_back_to_general() {
840        let bundle = tempfile::tempdir().unwrap();
841        let tenants_dir = bundle.path().join("tenants");
842        std::fs::create_dir_all(tenants_dir.join("delta")).unwrap();
843
844        // No domain-specific directory, should fall back
845        let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
846        assert_eq!(tenants, vec!["delta".to_string()]);
847    }
848}