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
175pub fn read_bundle_name(root: &Path) -> anyhow::Result<Option<String>> {
176    for path in [
177        root.join(BUNDLE_WORKSPACE_MARKER),
178        root.join(LEGACY_BUNDLE_MARKER),
179    ] {
180        if !path.exists() {
181            continue;
182        }
183        let raw =
184            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
185        let parsed: YamlValue =
186            serde_yaml_bw::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
187        let Some(map) = parsed.as_mapping() else {
188            continue;
189        };
190        if let Some(value) = yaml_get_string(map, "bundle_name") {
191            let value = value.trim();
192            if !value.is_empty() {
193                return Ok(Some(value.to_string()));
194            }
195        }
196    }
197    Ok(None)
198}
199
200/// Register pack references in both `bundle.yaml` and `bundle.lock.json`.
201pub fn register_bundle_references(
202    root: &Path,
203    refs: &[BundleReference],
204    bundle_name: Option<&str>,
205) -> anyhow::Result<()> {
206    let mut workspace = load_bundle_workspace_doc(root, bundle_name)?;
207    {
208        let map = yaml_object_mut(&mut workspace)?;
209        let mut app_packs = yaml_string_list(map, "app_packs");
210        let mut extension_providers = yaml_string_list(map, "extension_providers");
211
212        for entry in refs {
213            match entry.kind {
214                BundleReferenceKind::AppPack => app_packs.push(entry.reference.clone()),
215                BundleReferenceKind::ExtensionProvider => {
216                    extension_providers.push(entry.reference.clone())
217                }
218            }
219        }
220
221        sort_unique_strings(&mut app_packs);
222        sort_unique_strings(&mut extension_providers);
223        yaml_set_string_list(map, "app_packs", &app_packs);
224        yaml_set_string_list(map, "extension_providers", &extension_providers);
225    }
226
227    prune_scaffold_default_pack(root, &workspace)?;
228    write_bundle_workspace_doc(root, &workspace)?;
229    sync_bundle_lock_with_workspace(root, &workspace, refs)?;
230    Ok(())
231}
232
233/// Compute the gmap file path for a tenant/team in a bundle.
234pub fn gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
235    let mut path = bundle.join("tenants").join(tenant);
236    if let Some(team) = team {
237        path = path.join("teams").join(team).join("team.gmap");
238    } else {
239        path = path.join("tenant.gmap");
240    }
241    path
242}
243
244/// Compute the resolved manifest filename for a tenant/team.
245pub fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
246    match team {
247        Some(team) => format!("{tenant}.{team}.yaml"),
248        None => format!("{tenant}.yaml"),
249    }
250}
251
252/// Locate a provider's `.gtpack` file in the bundle by provider_id stem.
253pub fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<PathBuf> {
254    for subdir in &["providers/messaging", "providers/events", "packs"] {
255        let candidate = bundle.join(subdir).join(format!("{provider_id}.gtpack"));
256        if candidate.exists() {
257            return Some(candidate);
258        }
259    }
260    None
261}
262
263/// Discover tenants inside the bundle.
264///
265/// Scans `{bundle}/tenants/` for subdirectories and files, returning
266/// tenant names (directory names or file stems without extension).
267///
268/// If `domain` is provided, first checks `{bundle}/{domain}/tenants/`
269/// and falls back to the general `{bundle}/tenants/` directory.
270pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
271    // Try domain-specific tenants directory first
272    if let Some(domain_name) = domain {
273        let domain_dir = bundle.join(domain_name).join("tenants");
274        if let Some(tenants) = read_tenants_from_dir(&domain_dir)? {
275            return Ok(tenants);
276        }
277    }
278
279    // Fall back to general tenants directory
280    let general_dir = bundle.join("tenants");
281    if let Some(tenants) = read_tenants_from_dir(&general_dir)? {
282        return Ok(tenants);
283    }
284
285    Ok(Vec::new())
286}
287
288/// Read tenant names from a directory.
289fn read_tenants_from_dir(dir: &Path) -> anyhow::Result<Option<Vec<String>>> {
290    use std::collections::BTreeSet;
291
292    if !dir.exists() {
293        return Ok(None);
294    }
295
296    let mut tenants = BTreeSet::new();
297    for entry in std::fs::read_dir(dir)? {
298        let entry = entry?;
299        let path = entry.path();
300
301        if path.is_dir() {
302            if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
303                tenants.insert(name.to_string());
304            }
305            continue;
306        }
307
308        if path.is_file()
309            && let Some(stem) = path.file_stem().and_then(|v| v.to_str())
310        {
311            tenants.insert(stem.to_string());
312        }
313    }
314
315    Ok(Some(tenants.into_iter().collect()))
316}
317
318/// Read and parse the provider registry JSON from a bundle.
319pub fn load_provider_registry(bundle: &Path) -> anyhow::Result<serde_json::Value> {
320    let path = bundle.join("providers").join("providers.json");
321    if path.exists() {
322        let raw = std::fs::read_to_string(&path)
323            .with_context(|| format!("read provider registry {}", path.display()))?;
324        serde_json::from_str(&raw)
325            .with_context(|| format!("parse provider registry {}", path.display()))
326    } else {
327        Ok(serde_json::json!({ "providers": [] }))
328    }
329}
330
331/// Write the provider registry JSON to a bundle.
332pub fn write_provider_registry(bundle: &Path, root: &serde_json::Value) -> anyhow::Result<()> {
333    let path = bundle.join("providers").join("providers.json");
334    if let Some(parent) = path.parent() {
335        std::fs::create_dir_all(parent)?;
336    }
337    let payload = serde_json::to_string_pretty(root)
338        .with_context(|| format!("serialize provider registry {}", path.display()))?;
339    std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
340}
341
342fn load_bundle_workspace_doc(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<YamlValue> {
343    let path = root.join(BUNDLE_WORKSPACE_MARKER);
344    let mut doc = if path.exists() {
345        let raw =
346            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
347        serde_yaml_bw::from_str::<YamlValue>(&raw)
348            .with_context(|| format!("parse {}", path.display()))?
349    } else {
350        YamlValue::Mapping(YamlMapping::new())
351    };
352
353    let bundle_id = infer_bundle_id(root);
354    let bundle_name = bundle_name
355        .filter(|value| !value.trim().is_empty())
356        .map(ToOwned::to_owned)
357        .unwrap_or_else(|| infer_bundle_name(root));
358
359    let map = yaml_object_mut(&mut doc)?;
360    yaml_set_default(map, "schema_version", YamlValue::Number(1.into(), None));
361    yaml_set_default(map, "bundle_id", yaml_string(bundle_id.clone()));
362    yaml_set_default(map, "bundle_name", yaml_string(bundle_name));
363    yaml_set_default(map, "locale", yaml_string("en"));
364    yaml_set_default(map, "mode", yaml_string("create"));
365    yaml_set_default(map, "advanced_setup", YamlValue::Bool(false, None));
366    yaml_set_default(map, "app_packs", YamlValue::Sequence(YamlSequence::new()));
367    yaml_set_default(
368        map,
369        "app_pack_mappings",
370        YamlValue::Sequence(YamlSequence::new()),
371    );
372    yaml_set_default(
373        map,
374        "extension_providers",
375        YamlValue::Sequence(YamlSequence::new()),
376    );
377    yaml_set_default(
378        map,
379        "remote_catalogs",
380        YamlValue::Sequence(YamlSequence::new()),
381    );
382    yaml_set_default(map, "hooks", YamlValue::Sequence(YamlSequence::new()));
383    yaml_set_default(
384        map,
385        "subscriptions",
386        YamlValue::Sequence(YamlSequence::new()),
387    );
388    yaml_set_default(
389        map,
390        "capabilities",
391        YamlValue::Sequence(YamlSequence::new()),
392    );
393    yaml_set_default(map, "setup_execution_intent", YamlValue::Bool(false, None));
394    yaml_set_default(map, "export_intent", YamlValue::Bool(false, None));
395    Ok(doc)
396}
397
398fn write_bundle_workspace_doc(root: &Path, doc: &YamlValue) -> anyhow::Result<()> {
399    let path = root.join(BUNDLE_WORKSPACE_MARKER);
400    if let Some(parent) = path.parent() {
401        std::fs::create_dir_all(parent)?;
402    }
403    let mut rendered =
404        serde_yaml_bw::to_string(doc).with_context(|| format!("serialize {}", path.display()))?;
405    if let Some(stripped) = rendered.strip_prefix("---\n") {
406        rendered = stripped.to_string();
407    }
408    if !rendered.ends_with('\n') {
409        rendered.push('\n');
410    }
411    std::fs::write(&path, rendered).with_context(|| format!("write {}", path.display()))
412}
413
414fn sync_bundle_lock_with_workspace(
415    root: &Path,
416    workspace: &YamlValue,
417    updated_refs: &[BundleReference],
418) -> anyhow::Result<()> {
419    let path = root.join(BUNDLE_LOCK_FILE);
420    let mut doc = if path.exists() {
421        let raw =
422            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
423        serde_json::from_str::<JsonValue>(&raw)
424            .with_context(|| format!("parse {}", path.display()))?
425    } else {
426        JsonValue::Object(JsonMap::new())
427    };
428
429    let workspace_map = workspace
430        .as_mapping()
431        .ok_or_else(|| anyhow!("bundle workspace must be a YAML object"))?;
432    let bundle_id =
433        yaml_get_string(workspace_map, "bundle_id").unwrap_or_else(|| infer_bundle_id(root));
434    let mode = yaml_get_string(workspace_map, "mode").unwrap_or_else(|| "create".to_string());
435    let app_packs = yaml_string_list(workspace_map, "app_packs");
436    let extension_providers = yaml_string_list(workspace_map, "extension_providers");
437
438    let obj = json_object_mut(&mut doc)?;
439    json_set_default(obj, "schema_version", JsonValue::from(1));
440    json_set_default(obj, "bundle_id", JsonValue::String(bundle_id));
441    json_set_default(obj, "requested_mode", JsonValue::String(mode));
442    json_set_default(obj, "execution", JsonValue::String("execute".to_string()));
443    json_set_default(
444        obj,
445        "cache_policy",
446        JsonValue::String("workspace-local".to_string()),
447    );
448    obj.insert(
449        "tool_version".to_string(),
450        JsonValue::String(env!("CARGO_PKG_VERSION").to_string()),
451    );
452    json_set_default(
453        obj,
454        "build_format_version",
455        JsonValue::String("bundle-lock-v1".to_string()),
456    );
457    obj.insert(
458        "workspace_root".to_string(),
459        JsonValue::String(BUNDLE_WORKSPACE_MARKER.to_string()),
460    );
461    obj.insert(
462        "lock_file".to_string(),
463        JsonValue::String(BUNDLE_LOCK_FILE.to_string()),
464    );
465    json_set_default(obj, "catalogs", JsonValue::Array(Vec::new()));
466    json_set_default(obj, "setup_state_files", JsonValue::Array(Vec::new()));
467
468    let digests_by_ref: std::collections::BTreeMap<String, Option<String>> = updated_refs
469        .iter()
470        .map(|entry| (entry.reference.clone(), entry.digest.clone()))
471        .collect();
472    json_set_dependency_locks(obj, "app_packs", &app_packs, &digests_by_ref);
473    json_set_dependency_locks(
474        obj,
475        "extension_providers",
476        &extension_providers,
477        &digests_by_ref,
478    );
479
480    let payload = serde_json::to_string_pretty(&doc)
481        .with_context(|| format!("serialize {}", path.display()))?;
482    std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
483}
484
485fn prune_scaffold_default_pack(root: &Path, workspace: &YamlValue) -> anyhow::Result<()> {
486    let Some(workspace_map) = workspace.as_mapping() else {
487        return Ok(());
488    };
489    let app_packs = yaml_string_list(workspace_map, "app_packs");
490    let has_explicit_non_default = app_packs
491        .iter()
492        .any(|entry| !entry.ends_with("default.gtpack"));
493    if !has_explicit_non_default {
494        return Ok(());
495    }
496
497    let default_pack = root.join("packs").join("default.gtpack");
498    if !default_pack.exists() {
499        return Ok(());
500    }
501
502    let contents =
503        std::fs::read(&default_pack).with_context(|| format!("read {}", default_pack.display()))?;
504    if contents == EMBEDDED_WELCOME_PACK {
505        std::fs::remove_file(&default_pack)
506            .with_context(|| format!("remove {}", default_pack.display()))?;
507    }
508    Ok(())
509}
510
511pub(crate) fn infer_bundle_id(root: &Path) -> String {
512    root.file_name()
513        .and_then(|value| value.to_str())
514        .map(ToOwned::to_owned)
515        .filter(|value| !value.trim().is_empty())
516        .unwrap_or_else(|| "bundle".to_string())
517}
518
519fn infer_bundle_name(root: &Path) -> String {
520    infer_bundle_id(root)
521}
522
523fn yaml_object_mut(value: &mut YamlValue) -> anyhow::Result<&mut YamlMapping> {
524    if !matches!(value, YamlValue::Mapping(_)) {
525        *value = YamlValue::Mapping(YamlMapping::new());
526    }
527    match value {
528        YamlValue::Mapping(map) => Ok(map),
529        _ => unreachable!(),
530    }
531}
532
533fn yaml_set_default(map: &mut YamlMapping, key: &str, value: YamlValue) {
534    let key_value = yaml_string(key);
535    if !map.contains_key(&key_value) {
536        map.insert(key_value, value);
537    }
538}
539
540fn yaml_get_string(map: &YamlMapping, key: &str) -> Option<String> {
541    map.get(yaml_string(key))
542        .and_then(YamlValue::as_str)
543        .map(ToOwned::to_owned)
544}
545
546fn yaml_string_list(map: &YamlMapping, key: &str) -> Vec<String> {
547    map.get(yaml_string(key))
548        .and_then(YamlValue::as_sequence)
549        .map(|values| {
550            values
551                .iter()
552                .filter_map(YamlValue::as_str)
553                .map(ToOwned::to_owned)
554                .collect()
555        })
556        .unwrap_or_default()
557}
558
559fn yaml_set_string_list(map: &mut YamlMapping, key: &str, values: &[String]) {
560    let mut sequence = YamlSequence::new();
561    for value in values {
562        sequence.push(yaml_string(value.clone()));
563    }
564    map.insert(yaml_string(key), YamlValue::Sequence(sequence));
565}
566
567fn yaml_string(value: impl Into<String>) -> YamlValue {
568    YamlValue::String(value.into(), None)
569}
570
571fn sort_unique_strings(values: &mut Vec<String>) {
572    values.retain(|value| !value.trim().is_empty());
573    values.sort();
574    values.dedup();
575}
576
577fn json_object_mut(value: &mut JsonValue) -> anyhow::Result<&mut JsonMap<String, JsonValue>> {
578    if !matches!(value, JsonValue::Object(_)) {
579        *value = JsonValue::Object(JsonMap::new());
580    }
581    match value {
582        JsonValue::Object(map) => Ok(map),
583        _ => unreachable!(),
584    }
585}
586
587fn json_set_default(map: &mut JsonMap<String, JsonValue>, key: &str, value: JsonValue) {
588    map.entry(key.to_string()).or_insert(value);
589}
590
591fn json_set_dependency_locks(
592    map: &mut JsonMap<String, JsonValue>,
593    key: &str,
594    references: &[String],
595    updated_digests: &std::collections::BTreeMap<String, Option<String>>,
596) {
597    let existing_digests: std::collections::BTreeMap<String, Option<String>> = map
598        .get(key)
599        .and_then(JsonValue::as_array)
600        .map(|entries| {
601            entries
602                .iter()
603                .filter_map(|entry| {
604                    let obj = entry.as_object()?;
605                    let reference = obj.get("reference")?.as_str()?.to_string();
606                    let digest = obj
607                        .get("digest")
608                        .and_then(JsonValue::as_str)
609                        .map(ToOwned::to_owned);
610                    Some((reference, digest))
611                })
612                .collect()
613        })
614        .unwrap_or_default();
615
616    let entries = references
617        .iter()
618        .map(|reference| {
619            let digest = updated_digests
620                .get(reference)
621                .cloned()
622                .unwrap_or_else(|| existing_digests.get(reference).cloned().unwrap_or(None));
623            serde_json::json!({
624                "reference": reference,
625                "digest": digest,
626            })
627        })
628        .collect::<Vec<_>>();
629    map.insert(key.to_string(), JsonValue::Array(entries));
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::engine::execute_add_packs_to_bundle;
636    use crate::plan::ResolvedPackInfo;
637    use std::io::Write;
638    use zip::write::{FileOptions, ZipWriter};
639
640    fn write_pack(path: &Path, pack_id: &str) {
641        let file = std::fs::File::create(path).unwrap();
642        let mut writer = ZipWriter::new(file);
643        let options: FileOptions<'_, ()> =
644            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
645        writer.start_file("pack.manifest.json", options).unwrap();
646        writer
647            .write_all(
648                serde_json::json!({
649                    "pack_id": pack_id,
650                    "display_name": pack_id,
651                })
652                .to_string()
653                .as_bytes(),
654            )
655            .unwrap();
656        writer.finish().unwrap();
657    }
658
659    #[test]
660    fn create_bundle_structure() {
661        let temp = tempfile::tempdir().unwrap();
662        let root = temp.path().join("demo-bundle");
663        create_demo_bundle_structure(&root, Some("test")).unwrap();
664        assert!(root.join(LEGACY_BUNDLE_MARKER).exists());
665        assert!(root.join("providers/messaging").exists());
666        assert!(root.join("tenants/demo/teams/default/team.gmap").exists());
667    }
668
669    #[test]
670    fn embedded_welcome_pack_written_when_no_sibling() {
671        let temp = tempfile::tempdir().unwrap();
672        let root = temp.path().join("new-bundle");
673        create_demo_bundle_structure(&root, Some("test")).unwrap();
674        let pack = root.join("packs").join("default.gtpack");
675        assert!(pack.exists(), "embedded welcome pack should be written");
676        assert!(
677            pack.metadata().unwrap().len() > 1000,
678            "pack should not be empty"
679        );
680    }
681
682    #[test]
683    fn embedded_welcome_pack_not_overwritten() {
684        let temp = tempfile::tempdir().unwrap();
685        let root = temp.path().join("existing-bundle");
686        std::fs::create_dir_all(root.join("packs")).unwrap();
687        std::fs::write(root.join("packs").join("default.gtpack"), b"custom").unwrap();
688        create_demo_bundle_structure(&root, Some("test")).unwrap();
689        let contents = std::fs::read(root.join("packs").join("default.gtpack")).unwrap();
690        assert_eq!(
691            contents, b"custom",
692            "existing pack should not be overwritten"
693        );
694    }
695
696    #[test]
697    fn default_pack_skipped_when_bundle_has_app_packs() {
698        let temp = tempfile::tempdir().unwrap();
699        let root = temp.path().join("custom-bundle");
700        std::fs::create_dir_all(root.join("packs")).unwrap();
701        // Write a bundle.yaml that declares an app pack
702        std::fs::write(
703            root.join(BUNDLE_WORKSPACE_MARKER),
704            "schema_version: 1\napp_packs:\n  - packs/my-flow.pack\n",
705        )
706        .unwrap();
707        create_demo_bundle_structure(&root, Some("test")).unwrap();
708        assert!(
709            !root.join("packs").join("default.gtpack").exists(),
710            "default.gtpack should NOT be created when app_packs are declared"
711        );
712    }
713
714    #[test]
715    fn default_pack_skipped_when_bundle_has_external_app_pack_ref() {
716        let temp = tempfile::tempdir().unwrap();
717        let root = temp.path().join("external-ref-bundle");
718        std::fs::create_dir_all(root.join("packs")).unwrap();
719        std::fs::write(
720            root.join(BUNDLE_WORKSPACE_MARKER),
721            "schema_version: 1\napp_packs:\n  - demos/deep-research-demo.gtpack\n",
722        )
723        .unwrap();
724
725        create_demo_bundle_structure(&root, Some("test")).unwrap();
726
727        assert!(
728            !root.join("packs").join("default.gtpack").exists(),
729            "default.gtpack should NOT be created when bundle.yaml declares an external app pack ref"
730        );
731    }
732
733    #[test]
734    fn validate_bundle_exists_fails_for_missing() {
735        let result = validate_bundle_exists(Path::new("/nonexistent"));
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn validate_bundle_exists_accepts_bundle_yaml_workspace() {
741        let temp = tempfile::tempdir().unwrap();
742        let root = temp.path().join("bundle-workspace");
743        std::fs::create_dir_all(&root).unwrap();
744        std::fs::write(root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n").unwrap();
745
746        validate_bundle_exists(&root).unwrap();
747        assert!(is_bundle_root(&root));
748    }
749
750    #[test]
751    fn add_packs_updates_bundle_workspace_and_lock() {
752        let temp = tempfile::tempdir().unwrap();
753        let root = temp.path().join("bundle-workspace");
754        create_demo_bundle_structure(&root, Some("weather-demo")).unwrap();
755
756        let source_dir = temp.path().join("src-packs");
757        std::fs::create_dir_all(&source_dir).unwrap();
758        let app_pack = source_dir.join("weather-app.gtpack");
759        let provider_pack = source_dir.join("messaging-telegram.gtpack");
760        write_pack(&app_pack, "weather-app");
761        write_pack(&provider_pack, "messaging-telegram");
762
763        execute_add_packs_to_bundle(
764            &root,
765            &[
766                ResolvedPackInfo {
767                    source_ref: app_pack.display().to_string(),
768                    mapped_ref: app_pack.display().to_string(),
769                    resolved_digest: "sha256:app".to_string(),
770                    pack_id: "weather-app".to_string(),
771                    entry_flows: Vec::new(),
772                    cached_path: app_pack.clone(),
773                    output_path: app_pack.clone(),
774                },
775                ResolvedPackInfo {
776                    source_ref: provider_pack.display().to_string(),
777                    mapped_ref: provider_pack.display().to_string(),
778                    resolved_digest: "sha256:provider".to_string(),
779                    pack_id: "messaging-telegram".to_string(),
780                    entry_flows: Vec::new(),
781                    cached_path: provider_pack.clone(),
782                    output_path: provider_pack.clone(),
783                },
784            ],
785        )
786        .unwrap();
787
788        let bundle_yaml = std::fs::read_to_string(root.join(BUNDLE_WORKSPACE_MARKER)).unwrap();
789        assert!(bundle_yaml.contains("app_packs:"));
790        assert!(bundle_yaml.contains("packs/weather-app.gtpack"));
791        assert!(bundle_yaml.contains("extension_providers:"));
792        assert!(bundle_yaml.contains("providers/messaging/messaging-telegram.gtpack"));
793
794        let lock: serde_json::Value =
795            serde_json::from_str(&std::fs::read_to_string(root.join(BUNDLE_LOCK_FILE)).unwrap())
796                .unwrap();
797        assert_eq!(
798            lock.pointer("/app_packs/0/reference")
799                .and_then(serde_json::Value::as_str),
800            Some("packs/weather-app.gtpack")
801        );
802        assert_eq!(
803            lock.pointer("/app_packs/0/digest")
804                .and_then(serde_json::Value::as_str),
805            Some("sha256:app")
806        );
807        assert_eq!(
808            lock.pointer("/extension_providers/0/reference")
809                .and_then(serde_json::Value::as_str),
810            Some("providers/messaging/messaging-telegram.gtpack")
811        );
812        assert_eq!(
813            lock.pointer("/extension_providers/0/digest")
814                .and_then(serde_json::Value::as_str),
815            Some("sha256:provider")
816        );
817        assert!(
818            !root.join("packs").join("default.gtpack").exists(),
819            "scaffold welcome pack should be removed once an explicit app pack is added"
820        );
821    }
822
823    #[test]
824    fn gmap_paths() {
825        let p = gmap_path(Path::new("/b"), "demo", None);
826        assert_eq!(p, PathBuf::from("/b/tenants/demo/tenant.gmap"));
827
828        let p = gmap_path(Path::new("/b"), "demo", Some("ops"));
829        assert_eq!(p, PathBuf::from("/b/tenants/demo/teams/ops/team.gmap"));
830    }
831
832    #[test]
833    fn read_bundle_name_reads_workspace_metadata() {
834        let root = tempfile::tempdir().unwrap();
835        create_demo_bundle_structure(root.path(), Some("Provider Test")).unwrap();
836
837        assert_eq!(
838            read_bundle_name(root.path()).unwrap().as_deref(),
839            Some("Provider Test")
840        );
841    }
842
843    #[test]
844    fn resolved_manifest_filenames() {
845        assert_eq!(resolved_manifest_filename("demo", None), "demo.yaml");
846        assert_eq!(
847            resolved_manifest_filename("demo", Some("ops")),
848            "demo.ops.yaml"
849        );
850    }
851
852    #[test]
853    fn discover_tenants_reads_dirs_and_files() {
854        let bundle = tempfile::tempdir().unwrap();
855        let tenants_dir = bundle.path().join("tenants");
856        std::fs::create_dir_all(tenants_dir.join("alpha")).unwrap();
857        std::fs::write(tenants_dir.join("beta.json"), "{}").unwrap();
858
859        let tenants = discover_tenants(bundle.path(), None).unwrap();
860        assert!(tenants.contains(&"alpha".to_string()));
861        assert!(tenants.contains(&"beta".to_string()));
862    }
863
864    #[test]
865    fn discover_tenants_domain_specific() {
866        let bundle = tempfile::tempdir().unwrap();
867        let domain_dir = bundle.path().join("messaging").join("tenants");
868        std::fs::create_dir_all(domain_dir.join("gamma")).unwrap();
869
870        let tenants = discover_tenants(bundle.path(), Some("messaging")).unwrap();
871        assert_eq!(tenants, vec!["gamma".to_string()]);
872    }
873
874    #[test]
875    fn discover_tenants_falls_back_to_general() {
876        let bundle = tempfile::tempdir().unwrap();
877        let tenants_dir = bundle.path().join("tenants");
878        std::fs::create_dir_all(tenants_dir.join("delta")).unwrap();
879
880        // No domain-specific directory, should fall back
881        let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
882        assert_eq!(tenants, vec!["delta".to_string()]);
883    }
884}