1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use greentic_distributor_client::{
5 DistClient, DistOptions, OciPackFetcher, PackFetchOptions, oci_packs::DefaultRegistryClient,
6};
7use serde::{Deserialize, Serialize};
8use tokio::runtime::Runtime;
9
10pub const WORKSPACE_ROOT_FILE: &str = "bundle.yaml";
11pub const LOCK_FILE: &str = "bundle.lock.json";
12pub const LOCK_SCHEMA_VERSION: u32 = 1;
13
14const DEFAULT_GMAP: &str = "_ = forbidden\n";
15const GREENTIC_GTPACK_TAR_MEDIA_TYPE: &str = "application/vnd.greentic.gtpack.layer.v1+tar";
16const GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE: &str =
17 "application/vnd.greentic.gtpack.layer.v1.tar+gzip";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct BundleWorkspaceDefinition {
21 #[serde(default = "default_schema_version")]
22 pub schema_version: u32,
23 pub bundle_id: String,
24 pub bundle_name: String,
25 #[serde(default = "default_locale")]
26 pub locale: String,
27 #[serde(default = "default_mode")]
28 pub mode: String,
29 #[serde(default)]
30 pub advanced_setup: bool,
31 #[serde(default)]
32 pub app_packs: Vec<String>,
33 #[serde(default)]
34 pub app_pack_mappings: Vec<AppPackMapping>,
35 #[serde(default)]
36 pub extension_providers: Vec<String>,
37 #[serde(default)]
38 pub remote_catalogs: Vec<String>,
39 #[serde(default)]
40 pub hooks: Vec<String>,
41 #[serde(default)]
42 pub subscriptions: Vec<String>,
43 #[serde(default)]
44 pub capabilities: Vec<String>,
45 #[serde(default)]
46 pub setup_execution_intent: bool,
47 #[serde(default)]
48 pub export_intent: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct AppPackMapping {
53 pub reference: String,
54 pub scope: MappingScope,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tenant: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub team: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum MappingScope {
64 Global,
65 Tenant,
66 Team,
67}
68
69#[derive(Debug, Serialize)]
70struct ResolvedManifest {
71 version: String,
72 tenant: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 team: Option<String>,
75 project_root: String,
76 bundle: BundleSummary,
77 policy: PolicySection,
78 catalogs: Vec<String>,
79 app_packs: Vec<ResolvedReferencePolicy>,
80 extension_providers: Vec<String>,
81 hooks: Vec<String>,
82 subscriptions: Vec<String>,
83 capabilities: Vec<String>,
84}
85
86#[derive(Debug, Serialize)]
87struct BundleSummary {
88 bundle_id: String,
89 bundle_name: String,
90 locale: String,
91 mode: String,
92 advanced_setup: bool,
93 setup_execution_intent: bool,
94 export_intent: bool,
95}
96
97#[derive(Debug, Serialize)]
98struct PolicySection {
99 source: PolicySource,
100 default: String,
101}
102
103#[derive(Debug, Serialize)]
104struct PolicySource {
105 tenant_gmap: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 team_gmap: Option<String>,
108}
109
110#[derive(Debug, Serialize)]
111struct ResolvedReferencePolicy {
112 reference: String,
113 policy: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct BundleLock {
118 pub schema_version: u32,
119 pub bundle_id: String,
120 pub requested_mode: String,
121 pub execution: String,
122 pub cache_policy: String,
123 pub tool_version: String,
124 pub build_format_version: String,
125 pub workspace_root: String,
126 pub lock_file: String,
127 pub catalogs: Vec<crate::catalog::resolve::CatalogLockEntry>,
128 pub app_packs: Vec<DependencyLock>,
129 pub extension_providers: Vec<DependencyLock>,
130 pub setup_state_files: Vec<String>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct DependencyLock {
135 pub reference: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub digest: Option<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum ReferenceField {
142 AppPack,
143 ExtensionProvider,
144}
145
146impl BundleWorkspaceDefinition {
147 pub fn new(bundle_name: String, bundle_id: String, locale: String, mode: String) -> Self {
148 Self {
149 schema_version: default_schema_version(),
150 bundle_id,
151 bundle_name,
152 locale,
153 mode,
154 advanced_setup: false,
155 app_packs: Vec::new(),
156 app_pack_mappings: Vec::new(),
157 extension_providers: Vec::new(),
158 remote_catalogs: Vec::new(),
159 hooks: Vec::new(),
160 subscriptions: Vec::new(),
161 capabilities: Vec::new(),
162 setup_execution_intent: false,
163 export_intent: false,
164 }
165 }
166
167 pub fn canonicalize(&mut self) {
168 canonicalize_mappings(&mut self.app_pack_mappings);
169 self.app_packs.extend(
170 self.app_pack_mappings
171 .iter()
172 .map(|entry| entry.reference.clone()),
173 );
174 sort_unique(&mut self.app_packs);
175 sort_unique(&mut self.extension_providers);
176 sort_unique(&mut self.remote_catalogs);
177 sort_unique(&mut self.hooks);
178 sort_unique(&mut self.subscriptions);
179 sort_unique(&mut self.capabilities);
180 }
181
182 pub fn references(&self, field: ReferenceField) -> &[String] {
183 match field {
184 ReferenceField::AppPack => &self.app_packs,
185 ReferenceField::ExtensionProvider => &self.extension_providers,
186 }
187 }
188
189 pub fn references_mut(&mut self, field: ReferenceField) -> &mut Vec<String> {
190 match field {
191 ReferenceField::AppPack => &mut self.app_packs,
192 ReferenceField::ExtensionProvider => &mut self.extension_providers,
193 }
194 }
195}
196
197pub fn ensure_layout(root: &Path) -> Result<()> {
198 ensure_dir(&root.join("tenants"))?;
199 ensure_dir(&root.join("tenants").join("default"))?;
200 ensure_dir(&root.join("tenants").join("default").join("teams"))?;
201 ensure_dir(&root.join("resolved"))?;
202 ensure_dir(&root.join("state").join("resolved"))?;
203 write_if_missing(&root.join(WORKSPACE_ROOT_FILE), "schema_version: 1\n")?;
204 write_if_missing(
205 &root.join("tenants").join("default").join("tenant.gmap"),
206 DEFAULT_GMAP,
207 )?;
208 Ok(())
209}
210
211pub fn read_bundle_workspace(root: &Path) -> Result<BundleWorkspaceDefinition> {
212 let raw = std::fs::read_to_string(root.join(WORKSPACE_ROOT_FILE))?;
213 let mut definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&raw)?;
214 definition.canonicalize();
215 Ok(definition)
216}
217
218pub fn write_bundle_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
219 let mut workspace = workspace.clone();
220 workspace.canonicalize();
221 let path = root.join(WORKSPACE_ROOT_FILE);
222 if let Some(parent) = path.parent() {
223 ensure_dir(parent)?;
224 }
225 std::fs::write(path, render_bundle_workspace(&workspace))?;
226 Ok(())
227}
228
229pub fn init_bundle_workspace(
230 root: &Path,
231 workspace: &BundleWorkspaceDefinition,
232) -> Result<Vec<PathBuf>> {
233 ensure_layout(root)?;
234 write_bundle_workspace(root, workspace)?;
235 let lock = empty_bundle_lock(workspace);
236 write_bundle_lock(root, &lock)?;
237 sync_project(root)?;
238 Ok(vec![
239 root.join(WORKSPACE_ROOT_FILE),
240 root.join(LOCK_FILE),
241 root.join("tenants/default/tenant.gmap"),
242 root.join("resolved/default.yaml"),
243 root.join("state/resolved/default.yaml"),
244 ])
245}
246
247pub fn sync_lock_with_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
248 let mut lock = if root.join(LOCK_FILE).exists() {
249 read_bundle_lock(root)?
250 } else {
251 empty_bundle_lock(workspace)
252 };
253 lock.bundle_id = workspace.bundle_id.clone();
254 lock.requested_mode = workspace.mode.clone();
255 lock.workspace_root = WORKSPACE_ROOT_FILE.to_string();
256 lock.lock_file = LOCK_FILE.to_string();
257 lock.app_packs = workspace
258 .app_packs
259 .iter()
260 .map(|reference| DependencyLock {
261 reference: reference.clone(),
262 digest: None,
263 })
264 .collect();
265 lock.extension_providers = workspace
266 .extension_providers
267 .iter()
268 .map(|reference| DependencyLock {
269 reference: reference.clone(),
270 digest: None,
271 })
272 .collect();
273 write_bundle_lock(root, &lock)
274}
275
276pub fn ensure_tenant(root: &Path, tenant: &str) -> Result<()> {
277 let tenant_dir = root.join("tenants").join(tenant);
278 ensure_dir(&tenant_dir.join("teams"))?;
279 write_if_missing(&tenant_dir.join("tenant.gmap"), DEFAULT_GMAP)?;
280 Ok(())
281}
282
283pub fn ensure_team(root: &Path, tenant: &str, team: &str) -> Result<()> {
284 ensure_tenant(root, tenant)?;
285 let team_dir = root.join("tenants").join(tenant).join("teams").join(team);
286 ensure_dir(&team_dir)?;
287 write_if_missing(&team_dir.join("team.gmap"), DEFAULT_GMAP)?;
288 Ok(())
289}
290
291pub fn gmap_path(root: &Path, target: &crate::access::GmapTarget) -> PathBuf {
292 if let Some(team) = &target.team {
293 root.join("tenants")
294 .join(&target.tenant)
295 .join("teams")
296 .join(team)
297 .join("team.gmap")
298 } else {
299 root.join("tenants")
300 .join(&target.tenant)
301 .join("tenant.gmap")
302 }
303}
304
305pub fn resolved_output_paths(root: &Path, tenant: &str, team: Option<&str>) -> Vec<PathBuf> {
306 let filename = match team {
307 Some(team) => format!("{tenant}.{team}.yaml"),
308 None => format!("{tenant}.yaml"),
309 };
310 vec![
311 root.join("resolved").join(&filename),
312 root.join("state").join("resolved").join(filename),
313 ]
314}
315
316pub fn sync_project(root: &Path) -> Result<()> {
317 ensure_layout(root)?;
318 if let Ok(workspace) = read_bundle_workspace(root) {
319 materialize_workspace_dependencies(root, &workspace)?;
320 }
321 for tenant in list_tenants(root)? {
322 let teams = list_teams(root, &tenant)?;
323 if teams.is_empty() {
324 let manifest = build_manifest(root, &tenant, None);
325 write_resolved_outputs(root, &tenant, None, &manifest)?;
326 } else {
327 let tenant_manifest = build_manifest(root, &tenant, None);
328 write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
329 for team in teams {
330 let manifest = build_manifest(root, &tenant, Some(&team));
331 write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
332 }
333 }
334 }
335 Ok(())
336}
337
338fn materialize_workspace_dependencies(
339 root: &Path,
340 workspace: &BundleWorkspaceDefinition,
341) -> Result<()> {
342 for mapping in app_pack_copy_targets(workspace) {
343 materialize_reference_into(root, &mapping.reference, &mapping.destination)?;
344 }
345 for provider in &workspace.extension_providers {
346 let destination = provider_destination_path(provider);
347 materialize_reference_into(root, provider, &destination)?;
348 }
349 Ok(())
350}
351
352struct MaterializedCopyTarget {
353 reference: String,
354 destination: PathBuf,
355}
356
357fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
358 if workspace.app_pack_mappings.is_empty() {
359 return workspace
360 .app_packs
361 .iter()
362 .map(|reference| MaterializedCopyTarget {
363 reference: reference.clone(),
364 destination: PathBuf::from("packs")
365 .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
366 })
367 .collect();
368 }
369
370 workspace
371 .app_pack_mappings
372 .iter()
373 .map(|mapping| {
374 let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
375 let destination = match mapping.scope {
376 MappingScope::Global => PathBuf::from("packs").join(filename),
377 MappingScope::Tenant => PathBuf::from("tenants")
378 .join(mapping.tenant.as_deref().unwrap_or("default"))
379 .join("packs")
380 .join(filename),
381 MappingScope::Team => PathBuf::from("tenants")
382 .join(mapping.tenant.as_deref().unwrap_or("default"))
383 .join("teams")
384 .join(mapping.team.as_deref().unwrap_or("default"))
385 .join("packs")
386 .join(filename),
387 };
388 MaterializedCopyTarget {
389 reference: mapping.reference.clone(),
390 destination,
391 }
392 })
393 .collect()
394}
395
396fn provider_destination_path(reference: &str) -> PathBuf {
397 let provider_type = inferred_provider_type(reference);
398 let provider_name = inferred_provider_filename(reference);
399 PathBuf::from("providers")
400 .join(provider_type)
401 .join(format!("{provider_name}.gtpack"))
402}
403
404fn materialize_reference_into(
405 root: &Path,
406 reference: &str,
407 relative_destination: &Path,
408) -> Result<()> {
409 let destination = root.join(relative_destination);
410 if let Some(parent) = destination.parent() {
411 ensure_dir(parent)?;
412 }
413
414 if let Some(local_path) = parse_local_pack_reference(root, reference) {
415 if local_path.is_dir() {
416 return Ok(());
417 }
418 std::fs::copy(&local_path, &destination).with_context(|| {
419 format!("copy {} to {}", local_path.display(), destination.display())
420 })?;
421 return Ok(());
422 }
423
424 if !(reference.starts_with("oci://")
425 || reference.starts_with("repo://")
426 || reference.starts_with("store://"))
427 {
428 return Ok(());
429 }
430
431 let path = resolve_remote_pack_path(root, reference)?;
432 std::fs::copy(&path, &destination)
433 .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
434
435 Ok(())
436}
437
438fn parse_local_pack_reference(root: &Path, reference: &str) -> Option<PathBuf> {
439 if let Some(path) = reference.strip_prefix("file://") {
440 let path = PathBuf::from(path.trim());
441 return path.exists().then_some(path);
442 }
443 if reference.contains("://") {
444 return None;
445 }
446 let candidate = PathBuf::from(reference);
447 if candidate.is_absolute() {
448 return candidate.exists().then_some(candidate);
449 }
450 let joined = root.join(&candidate);
451 joined.exists().then_some(joined)
452}
453
454fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
455 if let Some(oci_reference) = reference.strip_prefix("oci://") {
456 let mut options = PackFetchOptions {
457 allow_tags: true,
458 offline: crate::runtime::offline(),
459 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
460 ..PackFetchOptions::default()
461 };
462 options.accepted_layer_media_types.extend([
463 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
464 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
465 ]);
466 options.preferred_layer_media_types.splice(
467 0..0,
468 [
469 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
470 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
471 ],
472 );
473 let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
474 let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
475 let resolved = runtime
476 .block_on(fetcher.fetch_pack_to_cache(oci_reference))
477 .with_context(|| format!("resolve OCI pack ref {reference}"))?;
478 return Ok(resolved.path);
479 }
480
481 let options = DistOptions {
482 allow_tags: true,
483 offline: crate::runtime::offline(),
484 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
485 ..DistOptions::default()
486 };
487 let client = DistClient::new(options);
488 let runtime = Runtime::new().context("create artifact resolver runtime")?;
489 let resolved = runtime
490 .block_on(client.resolve_ref(reference))
491 .with_context(|| format!("resolve artifact ref {reference}"))?;
492 if let Some(path) = resolved.wasm_path {
493 return Ok(path);
494 }
495 if let Some(bytes) = resolved.wasm_bytes {
496 let digest = resolved.resolved_digest.trim_start_matches("sha256:");
497 let temp_path = root
498 .join(crate::catalog::CACHE_ROOT_DIR)
499 .join("artifacts")
500 .join("inline")
501 .join(format!("{digest}.gtpack"));
502 if let Some(parent) = temp_path.parent() {
503 ensure_dir(parent)?;
504 }
505 std::fs::write(&temp_path, bytes)
506 .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
507 return Ok(temp_path);
508 }
509 anyhow::bail!("artifact ref {reference} resolved without file payload");
510}
511
512pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
513 let tenants_dir = root.join("tenants");
514 let mut tenants = Vec::new();
515 if !tenants_dir.exists() {
516 return Ok(tenants);
517 }
518 for entry in std::fs::read_dir(tenants_dir)? {
519 let entry = entry?;
520 if entry.file_type()?.is_dir() {
521 tenants.push(entry.file_name().to_string_lossy().to_string());
522 }
523 }
524 tenants.sort();
525 Ok(tenants)
526}
527
528pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
529 let teams_dir = root.join("tenants").join(tenant).join("teams");
530 let mut teams = Vec::new();
531 if !teams_dir.exists() {
532 return Ok(teams);
533 }
534 for entry in std::fs::read_dir(teams_dir)? {
535 let entry = entry?;
536 if entry.file_type()?.is_dir() {
537 teams.push(entry.file_name().to_string_lossy().to_string());
538 }
539 }
540 teams.sort();
541 Ok(teams)
542}
543
544pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
545 let path = root.join(LOCK_FILE);
546 if let Some(parent) = path.parent() {
547 ensure_dir(parent)?;
548 }
549 std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
550 Ok(())
551}
552
553pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
554 let path = root.join(LOCK_FILE);
555 let raw = std::fs::read_to_string(&path)?;
556 Ok(serde_json::from_str(&raw)?)
557}
558
559fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
560 let workspace = read_workspace_or_default(root);
561 let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
562 let team_gmap = team.map(|team| {
563 relative_path(
564 root,
565 &root
566 .join("tenants")
567 .join(tenant)
568 .join("teams")
569 .join(team)
570 .join("team.gmap"),
571 )
572 });
573
574 let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
575
576 ResolvedManifest {
577 version: "1".to_string(),
578 tenant: tenant.to_string(),
579 team: team.map(ToOwned::to_owned),
580 project_root: root.display().to_string(),
581 bundle: BundleSummary {
582 bundle_id: workspace.bundle_id,
583 bundle_name: workspace.bundle_name,
584 locale: workspace.locale,
585 mode: workspace.mode,
586 advanced_setup: workspace.advanced_setup,
587 setup_execution_intent: workspace.setup_execution_intent,
588 export_intent: workspace.export_intent,
589 },
590 policy: PolicySection {
591 source: PolicySource {
592 tenant_gmap,
593 team_gmap,
594 },
595 default: "forbidden".to_string(),
596 },
597 catalogs: workspace.remote_catalogs,
598 app_packs,
599 extension_providers: workspace.extension_providers,
600 hooks: workspace.hooks,
601 subscriptions: workspace.subscriptions,
602 capabilities: workspace.capabilities,
603 }
604}
605
606fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
607 format!(
608 concat!(
609 "schema_version: {}\n",
610 "bundle_id: {}\n",
611 "bundle_name: {}\n",
612 "locale: {}\n",
613 "mode: {}\n",
614 "advanced_setup: {}\n",
615 "app_packs:{}\n",
616 "app_pack_mappings:{}\n",
617 "extension_providers:{}\n",
618 "remote_catalogs:{}\n",
619 "hooks:{}\n",
620 "subscriptions:{}\n",
621 "capabilities:{}\n",
622 "setup_execution_intent: {}\n",
623 "export_intent: {}\n"
624 ),
625 workspace.schema_version,
626 workspace.bundle_id,
627 workspace.bundle_name,
628 workspace.locale,
629 workspace.mode,
630 workspace.advanced_setup,
631 yaml_list(&workspace.app_packs),
632 yaml_mapping_list(&workspace.app_pack_mappings),
633 yaml_list(&workspace.extension_providers),
634 yaml_list(&workspace.remote_catalogs),
635 yaml_list(&workspace.hooks),
636 yaml_list(&workspace.subscriptions),
637 yaml_list(&workspace.capabilities),
638 workspace.setup_execution_intent,
639 workspace.export_intent
640 )
641}
642
643fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
644 if values.is_empty() {
645 " []".to_string()
646 } else {
647 values
648 .iter()
649 .map(|value| {
650 let mut out = format!(
651 "\n - reference: {}\n scope: {}",
652 value.reference,
653 match value.scope {
654 MappingScope::Global => "global",
655 MappingScope::Tenant => "tenant",
656 MappingScope::Team => "team",
657 }
658 );
659 if let Some(tenant) = &value.tenant {
660 out.push_str(&format!("\n tenant: {tenant}"));
661 }
662 if let Some(team) = &value.team {
663 out.push_str(&format!("\n team: {team}"));
664 }
665 out
666 })
667 .collect::<String>()
668 }
669}
670
671fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
672 BundleLock {
673 schema_version: LOCK_SCHEMA_VERSION,
674 bundle_id: workspace.bundle_id.clone(),
675 requested_mode: workspace.mode.clone(),
676 execution: "execute".to_string(),
677 cache_policy: "workspace-local".to_string(),
678 tool_version: env!("CARGO_PKG_VERSION").to_string(),
679 build_format_version: "bundle-lock-v1".to_string(),
680 workspace_root: WORKSPACE_ROOT_FILE.to_string(),
681 lock_file: LOCK_FILE.to_string(),
682 catalogs: Vec::new(),
683 app_packs: workspace
684 .app_packs
685 .iter()
686 .map(|reference| DependencyLock {
687 reference: reference.clone(),
688 digest: None,
689 })
690 .collect(),
691 extension_providers: workspace
692 .extension_providers
693 .iter()
694 .map(|reference| DependencyLock {
695 reference: reference.clone(),
696 digest: None,
697 })
698 .collect(),
699 setup_state_files: Vec::new(),
700 }
701}
702
703fn yaml_list(values: &[String]) -> String {
704 if values.is_empty() {
705 " []".to_string()
706 } else {
707 values
708 .iter()
709 .map(|value| format!("\n - {value}"))
710 .collect::<String>()
711 }
712}
713
714fn sort_unique(values: &mut Vec<String>) {
715 values.retain(|value| !value.trim().is_empty());
716 values.sort();
717 values.dedup();
718}
719
720fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
721 values.retain(|value| !value.reference.trim().is_empty());
722 for value in values.iter_mut() {
723 if value
724 .tenant
725 .as_deref()
726 .is_some_and(|tenant| tenant.trim().is_empty())
727 {
728 value.tenant = None;
729 }
730 if value
731 .team
732 .as_deref()
733 .is_some_and(|team| team.trim().is_empty())
734 {
735 value.team = None;
736 }
737 if matches!(value.scope, MappingScope::Global) {
738 value.tenant = None;
739 value.team = None;
740 } else if matches!(value.scope, MappingScope::Tenant) {
741 value.team = None;
742 }
743 }
744 values.sort_by(|left, right| {
745 left.reference
746 .cmp(&right.reference)
747 .then(left.scope.cmp(&right.scope))
748 .then(left.tenant.cmp(&right.tenant))
749 .then(left.team.cmp(&right.team))
750 });
751 values.dedup_by(|left, right| {
752 left.reference == right.reference
753 && left.scope == right.scope
754 && left.tenant == right.tenant
755 && left.team == right.team
756 });
757}
758
759fn default_schema_version() -> u32 {
760 1
761}
762
763fn default_locale() -> String {
764 "en".to_string()
765}
766
767fn default_mode() -> String {
768 "create".to_string()
769}
770
771fn write_resolved_outputs(
772 root: &Path,
773 tenant: &str,
774 team: Option<&str>,
775 manifest: &ResolvedManifest,
776) -> Result<()> {
777 let yaml = render_manifest_yaml(manifest);
778 for output in resolved_output_paths(root, tenant, team) {
779 if let Some(parent) = output.parent() {
780 ensure_dir(parent)?;
781 }
782 std::fs::write(output, &yaml)?;
783 }
784 Ok(())
785}
786
787fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
788 let mut lines = vec![
789 format!("version: {}", manifest.version),
790 format!("tenant: {}", manifest.tenant),
791 ];
792 if let Some(team) = &manifest.team {
793 lines.push(format!("team: {}", team));
794 }
795 lines.extend([
796 format!("project_root: {}", manifest.project_root),
797 "bundle:".to_string(),
798 format!(" bundle_id: {}", manifest.bundle.bundle_id),
799 format!(" bundle_name: {}", manifest.bundle.bundle_name),
800 format!(" locale: {}", manifest.bundle.locale),
801 format!(" mode: {}", manifest.bundle.mode),
802 format!(" advanced_setup: {}", manifest.bundle.advanced_setup),
803 format!(
804 " setup_execution_intent: {}",
805 manifest.bundle.setup_execution_intent
806 ),
807 format!(" export_intent: {}", manifest.bundle.export_intent),
808 "policy:".to_string(),
809 " source:".to_string(),
810 format!(" tenant_gmap: {}", manifest.policy.source.tenant_gmap),
811 ]);
812 if let Some(team_gmap) = &manifest.policy.source.team_gmap {
813 lines.push(format!(" team_gmap: {}", team_gmap));
814 }
815 lines.push(format!(" default: {}", manifest.policy.default));
816 lines.push("catalogs:".to_string());
817 lines.extend(render_yaml_list(" ", &manifest.catalogs));
818 lines.push("app_packs:".to_string());
819 if manifest.app_packs.is_empty() {
820 lines.push(" []".to_string());
821 } else {
822 for entry in &manifest.app_packs {
823 lines.push(format!(" - reference: {}", entry.reference));
824 lines.push(format!(" policy: {}", entry.policy));
825 }
826 }
827 lines.push("extension_providers:".to_string());
828 lines.extend(render_yaml_list(" ", &manifest.extension_providers));
829 lines.push("hooks:".to_string());
830 lines.extend(render_yaml_list(" ", &manifest.hooks));
831 lines.push("subscriptions:".to_string());
832 lines.extend(render_yaml_list(" ", &manifest.subscriptions));
833 lines.push("capabilities:".to_string());
834 lines.extend(render_yaml_list(" ", &manifest.capabilities));
835 format!("{}\n", lines.join("\n"))
836}
837
838fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
839 read_bundle_workspace(root).unwrap_or_else(|_| {
840 let bundle_id = root
841 .file_name()
842 .and_then(|value| value.to_str())
843 .map(ToOwned::to_owned)
844 .filter(|value| !value.trim().is_empty())
845 .unwrap_or_else(|| "bundle".to_string());
846 BundleWorkspaceDefinition::new(
847 bundle_id.clone(),
848 bundle_id,
849 default_locale(),
850 default_mode(),
851 )
852 })
853}
854
855fn evaluate_app_pack_policies(
856 root: &Path,
857 tenant: &str,
858 team: Option<&str>,
859 app_packs: &[String],
860) -> Vec<ResolvedReferencePolicy> {
861 let tenant_rules =
862 crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
863 .unwrap_or_default();
864 let team_rules = team
865 .and_then(|team_name| {
866 crate::access::parse_file(
867 &root
868 .join("tenants")
869 .join(tenant)
870 .join("teams")
871 .join(team_name)
872 .join("team.gmap"),
873 )
874 .ok()
875 })
876 .unwrap_or_default();
877
878 let mut entries = app_packs
879 .iter()
880 .map(|reference| {
881 let target = crate::access::GmapPath {
882 pack: Some(inferred_access_pack_id(reference)),
883 flow: None,
884 node: None,
885 };
886 let policy = if team.is_some() {
887 crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
888 } else {
889 crate::access::eval_policy(&tenant_rules, &target)
890 };
891 ResolvedReferencePolicy {
892 reference: reference.clone(),
893 policy: policy
894 .map(|decision| decision.policy.to_string())
895 .unwrap_or_else(|| "unset".to_string()),
896 }
897 })
898 .collect::<Vec<_>>();
899 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
900 entries
901}
902
903fn inferred_access_pack_id(reference: &str) -> String {
904 let cleaned = reference
905 .trim_end_matches('/')
906 .rsplit('/')
907 .next()
908 .unwrap_or(reference)
909 .split('@')
910 .next()
911 .unwrap_or(reference)
912 .split(':')
913 .next()
914 .unwrap_or(reference)
915 .trim_end_matches(".json")
916 .trim_end_matches(".gtpack")
917 .trim_end_matches(".yaml")
918 .trim_end_matches(".yml");
919 let mut normalized = String::with_capacity(cleaned.len());
920 let mut last_dash = false;
921 for ch in cleaned.chars() {
922 let out = if ch.is_ascii_alphanumeric() {
923 last_dash = false;
924 ch.to_ascii_lowercase()
925 } else if last_dash {
926 continue;
927 } else {
928 last_dash = true;
929 '-'
930 };
931 normalized.push(out);
932 }
933 normalized.trim_matches('-').to_string()
934}
935
936fn inferred_provider_type(reference: &str) -> String {
937 let raw = reference.trim();
938 for marker in ["/providers/", "/packs/"] {
939 if let Some((_, rest)) = raw.split_once(marker)
940 && let Some(segment) = rest.split('/').next()
941 && !segment.is_empty()
942 {
943 return segment.to_string();
944 }
945 }
946
947 let inferred = inferred_access_pack_id(reference);
948 let mut parts = inferred.split('-');
949 match (parts.next(), parts.next()) {
950 (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
951 (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
952 (Some(_domain), None) => "other".to_string(),
953 _ => "other".to_string(),
954 }
955}
956
957fn inferred_provider_filename(reference: &str) -> String {
958 let cleaned = reference
959 .trim_end_matches('/')
960 .rsplit('/')
961 .next()
962 .unwrap_or(reference)
963 .split('@')
964 .next()
965 .unwrap_or(reference)
966 .split(':')
967 .next()
968 .unwrap_or(reference)
969 .trim_end_matches(".gtpack");
970 if cleaned.is_empty() {
971 inferred_access_pack_id(reference)
972 } else {
973 cleaned.to_string()
974 }
975}
976
977fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
978 if values.is_empty() {
979 vec![format!("{indent}[]")]
980 } else {
981 values
982 .iter()
983 .map(|value| format!("{indent}- {value}"))
984 .collect()
985 }
986}
987
988fn relative_path(root: &Path, path: &Path) -> String {
989 path.strip_prefix(root)
990 .unwrap_or(path)
991 .display()
992 .to_string()
993}
994
995fn ensure_dir(path: &Path) -> Result<()> {
996 std::fs::create_dir_all(path)?;
997 Ok(())
998}
999
1000fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1001 if path.exists() {
1002 return Ok(());
1003 }
1004 if let Some(parent) = path.parent() {
1005 ensure_dir(parent)?;
1006 }
1007 std::fs::write(path, contents)?;
1008 Ok(())
1009}