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 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
112const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
118
119fn 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
135pub 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
147pub 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
167pub 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 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
208pub 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
219pub 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
227pub 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
238pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
246 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 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
263fn 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
293pub 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
306pub 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 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 let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
846 assert_eq!(tenants, vec!["delta".to_string()]);
847 }
848}