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 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
200pub 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
233pub 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
244pub 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
252pub 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
263pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
271 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 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
288fn 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
318pub 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
331pub 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 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 let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
882 assert_eq!(tenants, vec!["delta".to_string()]);
883 }
884}