Skip to main content

greentic_bundle/project/
mod.rs

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}