1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum BundleReferenceKind {
19 AppPack,
20 ExtensionProvider,
21}
22
23#[derive(Clone, Debug)]
25pub struct BundleReference {
26 pub kind: BundleReferenceKind,
27 pub reference: String,
28 pub digest: Option<String>,
29}
30
31pub 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 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
97fn 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 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
114const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
120
121fn 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
137pub 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
149pub 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
169pub 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
177pub 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
210pub 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
221pub 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
229pub 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
240pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
248 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 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
265fn 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
295pub 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
308pub 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 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 let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
829 assert_eq!(tenants, vec!["delta".to_string()]);
830 }
831}