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