Skip to main content

greentic_bundle/project/
mod.rs

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