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    sync_project_with_reference_roots(root, &[])
338}
339
340pub fn sync_project_with_reference_roots(root: &Path, reference_roots: &[PathBuf]) -> Result<()> {
341    ensure_layout(root)?;
342    if let Ok(workspace) = read_bundle_workspace(root) {
343        materialize_workspace_dependencies(root, &workspace, reference_roots)?;
344    }
345    for tenant in list_tenants(root)? {
346        let teams = list_teams(root, &tenant)?;
347        if teams.is_empty() {
348            let manifest = build_manifest(root, &tenant, None);
349            write_resolved_outputs(root, &tenant, None, &manifest)?;
350        } else {
351            let tenant_manifest = build_manifest(root, &tenant, None);
352            write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
353            for team in teams {
354                let manifest = build_manifest(root, &tenant, Some(&team));
355                write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
356            }
357        }
358    }
359    Ok(())
360}
361
362fn materialize_workspace_dependencies(
363    root: &Path,
364    workspace: &BundleWorkspaceDefinition,
365    reference_roots: &[PathBuf],
366) -> Result<()> {
367    let app_targets = app_pack_copy_targets(workspace);
368    let provider_targets: Vec<_> = workspace
369        .extension_providers
370        .iter()
371        .filter(|p| !should_skip_extension_provider_materialization(p))
372        .collect();
373    let total = app_targets.len() + provider_targets.len();
374    let mut current = 0usize;
375
376    for mapping in &app_targets {
377        current += 1;
378        let dest = root.join(&mapping.destination);
379        if dest.exists() {
380            eprintln!("  [{current}/{total}] Cached: {}", mapping.reference);
381        } else {
382            eprintln!(
383                "  [{current}/{total}] Resolving app pack: {}",
384                mapping.reference
385            );
386        }
387        materialize_reference_into(
388            root,
389            reference_roots,
390            &mapping.reference,
391            &mapping.destination,
392        )?;
393    }
394    for provider in &provider_targets {
395        current += 1;
396        let destination = provider_destination_path(provider);
397        let dest = root.join(&destination);
398        if dest.exists() {
399            eprintln!("  [{current}/{total}] Cached: {provider}");
400        } else {
401            eprintln!("  [{current}/{total}] Resolving provider: {provider}");
402        }
403        materialize_reference_into(root, reference_roots, provider, &destination)?;
404    }
405    if total > 0 {
406        eprintln!("  [done] Resolved {total} package(s)");
407    }
408    Ok(())
409}
410
411fn should_skip_extension_provider_materialization(reference: &str) -> bool {
412    bundled_catalog_mode()
413        && (reference.starts_with("oci://")
414            || reference.starts_with("repo://")
415            || reference.starts_with("store://")
416            || reference.starts_with("https://"))
417}
418
419fn bundled_catalog_mode() -> bool {
420    std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
421        .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
422        .unwrap_or(false)
423}
424
425struct MaterializedCopyTarget {
426    reference: String,
427    destination: PathBuf,
428}
429
430fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
431    if workspace.app_pack_mappings.is_empty() {
432        return workspace
433            .app_packs
434            .iter()
435            .map(|reference| MaterializedCopyTarget {
436                reference: reference.clone(),
437                destination: PathBuf::from("packs")
438                    .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
439            })
440            .collect();
441    }
442
443    workspace
444        .app_pack_mappings
445        .iter()
446        .map(|mapping| {
447            let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
448            let destination = match mapping.scope {
449                MappingScope::Global => PathBuf::from("packs").join(filename),
450                MappingScope::Tenant => PathBuf::from("tenants")
451                    .join(mapping.tenant.as_deref().unwrap_or("default"))
452                    .join("packs")
453                    .join(filename),
454                MappingScope::Team => PathBuf::from("tenants")
455                    .join(mapping.tenant.as_deref().unwrap_or("default"))
456                    .join("teams")
457                    .join(mapping.team.as_deref().unwrap_or("default"))
458                    .join("packs")
459                    .join(filename),
460            };
461            MaterializedCopyTarget {
462                reference: mapping.reference.clone(),
463                destination,
464            }
465        })
466        .collect()
467}
468
469fn provider_destination_path(reference: &str) -> PathBuf {
470    let provider_type = inferred_provider_type(reference);
471    let provider_name = inferred_provider_filename(reference);
472    PathBuf::from("providers")
473        .join(provider_type)
474        .join(format!("{provider_name}.gtpack"))
475}
476
477fn materialize_reference_into(
478    root: &Path,
479    reference_roots: &[PathBuf],
480    reference: &str,
481    relative_destination: &Path,
482) -> Result<()> {
483    let destination = root.join(relative_destination);
484    if destination.exists() {
485        return Ok(());
486    }
487    if let Some(parent) = destination.parent() {
488        ensure_dir(parent)?;
489    }
490
491    if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
492        if local_path.is_dir() {
493            return Ok(());
494        }
495        std::fs::copy(&local_path, &destination).with_context(|| {
496            format!("copy {} to {}", local_path.display(), destination.display())
497        })?;
498        return Ok(());
499    }
500
501    if !(reference.starts_with("oci://")
502        || reference.starts_with("repo://")
503        || reference.starts_with("store://")
504        || reference.starts_with("https://"))
505    {
506        return Ok(());
507    }
508
509    let path = resolve_remote_pack_path(root, reference)?;
510    std::fs::copy(&path, &destination)
511        .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
512
513    Ok(())
514}
515
516fn parse_local_pack_reference(
517    root: &Path,
518    reference_roots: &[PathBuf],
519    reference: &str,
520) -> Option<PathBuf> {
521    if let Some(path) = reference.strip_prefix("file://") {
522        let path = PathBuf::from(path.trim());
523        if path.is_absolute() {
524            return path.exists().then_some(path);
525        }
526        for base in reference_roots
527            .iter()
528            .map(PathBuf::as_path)
529            .chain(std::iter::once(root))
530        {
531            let candidate = base.join(&path);
532            if candidate.exists() {
533                return Some(candidate);
534            }
535        }
536        return None;
537    }
538    if reference.contains("://") {
539        return None;
540    }
541    let candidate = PathBuf::from(reference);
542    if candidate.is_absolute() {
543        return candidate.exists().then_some(candidate);
544    }
545    for base in reference_roots
546        .iter()
547        .map(PathBuf::as_path)
548        .chain(std::iter::once(root))
549    {
550        let joined = base.join(&candidate);
551        if joined.exists() {
552            return Some(joined);
553        }
554    }
555    None
556}
557
558fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
559    if let Some(oci_reference) = reference.strip_prefix("oci://") {
560        let mut options = PackFetchOptions {
561            allow_tags: true,
562            offline: crate::runtime::offline(),
563            cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
564            ..PackFetchOptions::default()
565        };
566        options.accepted_layer_media_types.extend([
567            GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
568            GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
569        ]);
570        options.preferred_layer_media_types.splice(
571            0..0,
572            [
573                GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
574                GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
575            ],
576        );
577        let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
578        let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
579        let resolved = runtime
580            .block_on(fetcher.fetch_pack_to_cache(oci_reference))
581            .with_context(|| format!("resolve OCI pack ref {reference}"))?;
582        return Ok(resolved.path);
583    }
584
585    let options = DistOptions {
586        allow_tags: true,
587        offline: crate::runtime::offline(),
588        cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
589        ..DistOptions::default()
590    };
591    let client = DistClient::new(options);
592    let runtime = Runtime::new().context("create artifact resolver runtime")?;
593    let source = client
594        .parse_source(reference)
595        .with_context(|| format!("parse artifact ref {reference}"))?;
596    let descriptor = runtime
597        .block_on(client.resolve(source, ResolvePolicy))
598        .with_context(|| format!("resolve artifact ref {reference}"))?;
599    let resolved = runtime
600        .block_on(client.fetch(&descriptor, CachePolicy))
601        .with_context(|| format!("fetch artifact ref {reference}"))?;
602    if let Some(path) = resolved.wasm_path {
603        return Ok(path);
604    }
605    if let Some(bytes) = resolved.wasm_bytes {
606        let digest = resolved.resolved_digest.trim_start_matches("sha256:");
607        let temp_path = root
608            .join(crate::catalog::CACHE_ROOT_DIR)
609            .join("artifacts")
610            .join("inline")
611            .join(format!("{digest}.gtpack"));
612        if let Some(parent) = temp_path.parent() {
613            ensure_dir(parent)?;
614        }
615        std::fs::write(&temp_path, bytes)
616            .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
617        return Ok(temp_path);
618    }
619    anyhow::bail!("artifact ref {reference} resolved without file payload");
620}
621
622pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
623    let tenants_dir = root.join("tenants");
624    let mut tenants = Vec::new();
625    if !tenants_dir.exists() {
626        return Ok(tenants);
627    }
628    for entry in std::fs::read_dir(tenants_dir)? {
629        let entry = entry?;
630        if entry.file_type()?.is_dir() {
631            tenants.push(entry.file_name().to_string_lossy().to_string());
632        }
633    }
634    tenants.sort();
635    Ok(tenants)
636}
637
638pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
639    let teams_dir = root.join("tenants").join(tenant).join("teams");
640    let mut teams = Vec::new();
641    if !teams_dir.exists() {
642        return Ok(teams);
643    }
644    for entry in std::fs::read_dir(teams_dir)? {
645        let entry = entry?;
646        if entry.file_type()?.is_dir() {
647            teams.push(entry.file_name().to_string_lossy().to_string());
648        }
649    }
650    teams.sort();
651    Ok(teams)
652}
653
654pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
655    let path = root.join(LOCK_FILE);
656    if let Some(parent) = path.parent() {
657        ensure_dir(parent)?;
658    }
659    std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
660    Ok(())
661}
662
663pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
664    let path = root.join(LOCK_FILE);
665    let raw = std::fs::read_to_string(&path)?;
666    Ok(serde_json::from_str(&raw)?)
667}
668
669fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
670    let workspace = read_workspace_or_default(root);
671    let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
672    let team_gmap = team.map(|team| {
673        relative_path(
674            root,
675            &root
676                .join("tenants")
677                .join(tenant)
678                .join("teams")
679                .join(team)
680                .join("team.gmap"),
681        )
682    });
683
684    let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
685
686    ResolvedManifest {
687        version: "1".to_string(),
688        tenant: tenant.to_string(),
689        team: team.map(ToOwned::to_owned),
690        project_root: root.display().to_string(),
691        bundle: BundleSummary {
692            bundle_id: workspace.bundle_id,
693            bundle_name: workspace.bundle_name,
694            locale: workspace.locale,
695            mode: workspace.mode,
696            advanced_setup: workspace.advanced_setup,
697            setup_execution_intent: workspace.setup_execution_intent,
698            export_intent: workspace.export_intent,
699        },
700        policy: PolicySection {
701            source: PolicySource {
702                tenant_gmap,
703                team_gmap,
704            },
705            default: "forbidden".to_string(),
706        },
707        catalogs: workspace.remote_catalogs,
708        app_packs,
709        extension_providers: workspace.extension_providers,
710        hooks: workspace.hooks,
711        subscriptions: workspace.subscriptions,
712        capabilities: workspace.capabilities,
713    }
714}
715
716fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
717    format!(
718        concat!(
719            "schema_version: {}\n",
720            "bundle_id: {}\n",
721            "bundle_name: {}\n",
722            "locale: {}\n",
723            "mode: {}\n",
724            "advanced_setup: {}\n",
725            "app_packs:{}\n",
726            "app_pack_mappings:{}\n",
727            "extension_providers:{}\n",
728            "remote_catalogs:{}\n",
729            "hooks:{}\n",
730            "subscriptions:{}\n",
731            "capabilities:{}\n",
732            "setup_execution_intent: {}\n",
733            "export_intent: {}\n"
734        ),
735        workspace.schema_version,
736        workspace.bundle_id,
737        workspace.bundle_name,
738        workspace.locale,
739        workspace.mode,
740        workspace.advanced_setup,
741        yaml_list(&workspace.app_packs),
742        yaml_mapping_list(&workspace.app_pack_mappings),
743        yaml_list(&workspace.extension_providers),
744        yaml_list(&workspace.remote_catalogs),
745        yaml_list(&workspace.hooks),
746        yaml_list(&workspace.subscriptions),
747        yaml_list(&workspace.capabilities),
748        workspace.setup_execution_intent,
749        workspace.export_intent
750    )
751}
752
753fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
754    if values.is_empty() {
755        " []".to_string()
756    } else {
757        values
758            .iter()
759            .map(|value| {
760                let mut out = format!(
761                    "\n  - reference: {}\n    scope: {}",
762                    value.reference,
763                    match value.scope {
764                        MappingScope::Global => "global",
765                        MappingScope::Tenant => "tenant",
766                        MappingScope::Team => "team",
767                    }
768                );
769                if let Some(tenant) = &value.tenant {
770                    out.push_str(&format!("\n    tenant: {tenant}"));
771                }
772                if let Some(team) = &value.team {
773                    out.push_str(&format!("\n    team: {team}"));
774                }
775                out
776            })
777            .collect::<String>()
778    }
779}
780
781fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
782    BundleLock {
783        schema_version: LOCK_SCHEMA_VERSION,
784        bundle_id: workspace.bundle_id.clone(),
785        requested_mode: workspace.mode.clone(),
786        execution: "execute".to_string(),
787        cache_policy: "workspace-local".to_string(),
788        tool_version: env!("CARGO_PKG_VERSION").to_string(),
789        build_format_version: "bundle-lock-v1".to_string(),
790        workspace_root: WORKSPACE_ROOT_FILE.to_string(),
791        lock_file: LOCK_FILE.to_string(),
792        catalogs: Vec::new(),
793        app_packs: workspace
794            .app_packs
795            .iter()
796            .map(|reference| DependencyLock {
797                reference: reference.clone(),
798                digest: None,
799            })
800            .collect(),
801        extension_providers: workspace
802            .extension_providers
803            .iter()
804            .map(|reference| DependencyLock {
805                reference: reference.clone(),
806                digest: None,
807            })
808            .collect(),
809        setup_state_files: Vec::new(),
810    }
811}
812
813fn yaml_list(values: &[String]) -> String {
814    if values.is_empty() {
815        " []".to_string()
816    } else {
817        values
818            .iter()
819            .map(|value| format!("\n  - {value}"))
820            .collect::<String>()
821    }
822}
823
824fn sort_unique(values: &mut Vec<String>) {
825    values.retain(|value| !value.trim().is_empty());
826    values.sort();
827    values.dedup();
828}
829
830fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
831    values.retain(|value| !value.reference.trim().is_empty());
832    for value in values.iter_mut() {
833        if value
834            .tenant
835            .as_deref()
836            .is_some_and(|tenant| tenant.trim().is_empty())
837        {
838            value.tenant = None;
839        }
840        if value
841            .team
842            .as_deref()
843            .is_some_and(|team| team.trim().is_empty())
844        {
845            value.team = None;
846        }
847        if matches!(value.scope, MappingScope::Global) {
848            value.tenant = None;
849            value.team = None;
850        } else if matches!(value.scope, MappingScope::Tenant) {
851            value.team = None;
852        }
853    }
854    values.sort_by(|left, right| {
855        left.reference
856            .cmp(&right.reference)
857            .then(left.scope.cmp(&right.scope))
858            .then(left.tenant.cmp(&right.tenant))
859            .then(left.team.cmp(&right.team))
860    });
861    values.dedup_by(|left, right| {
862        left.reference == right.reference
863            && left.scope == right.scope
864            && left.tenant == right.tenant
865            && left.team == right.team
866    });
867}
868
869fn default_schema_version() -> u32 {
870    1
871}
872
873fn default_locale() -> String {
874    "en".to_string()
875}
876
877fn default_mode() -> String {
878    "create".to_string()
879}
880
881fn write_resolved_outputs(
882    root: &Path,
883    tenant: &str,
884    team: Option<&str>,
885    manifest: &ResolvedManifest,
886) -> Result<()> {
887    let yaml = render_manifest_yaml(manifest);
888    for output in resolved_output_paths(root, tenant, team) {
889        if let Some(parent) = output.parent() {
890            ensure_dir(parent)?;
891        }
892        std::fs::write(output, &yaml)?;
893    }
894    Ok(())
895}
896
897fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
898    let mut lines = vec![
899        format!("version: {}", manifest.version),
900        format!("tenant: {}", manifest.tenant),
901    ];
902    if let Some(team) = &manifest.team {
903        lines.push(format!("team: {}", team));
904    }
905    lines.extend([
906        format!("project_root: {}", manifest.project_root),
907        "bundle:".to_string(),
908        format!("  bundle_id: {}", manifest.bundle.bundle_id),
909        format!("  bundle_name: {}", manifest.bundle.bundle_name),
910        format!("  locale: {}", manifest.bundle.locale),
911        format!("  mode: {}", manifest.bundle.mode),
912        format!("  advanced_setup: {}", manifest.bundle.advanced_setup),
913        format!(
914            "  setup_execution_intent: {}",
915            manifest.bundle.setup_execution_intent
916        ),
917        format!("  export_intent: {}", manifest.bundle.export_intent),
918        "policy:".to_string(),
919        "  source:".to_string(),
920        format!("    tenant_gmap: {}", manifest.policy.source.tenant_gmap),
921    ]);
922    if let Some(team_gmap) = &manifest.policy.source.team_gmap {
923        lines.push(format!("    team_gmap: {}", team_gmap));
924    }
925    lines.push(format!("  default: {}", manifest.policy.default));
926    lines.push("catalogs:".to_string());
927    lines.extend(render_yaml_list("  ", &manifest.catalogs));
928    lines.push("app_packs:".to_string());
929    if manifest.app_packs.is_empty() {
930        lines.push("  []".to_string());
931    } else {
932        for entry in &manifest.app_packs {
933            lines.push(format!("  - reference: {}", entry.reference));
934            lines.push(format!("    policy: {}", entry.policy));
935        }
936    }
937    lines.push("extension_providers:".to_string());
938    lines.extend(render_yaml_list("  ", &manifest.extension_providers));
939    lines.push("hooks:".to_string());
940    lines.extend(render_yaml_list("  ", &manifest.hooks));
941    lines.push("subscriptions:".to_string());
942    lines.extend(render_yaml_list("  ", &manifest.subscriptions));
943    lines.push("capabilities:".to_string());
944    lines.extend(render_yaml_list("  ", &manifest.capabilities));
945    format!("{}\n", lines.join("\n"))
946}
947
948fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
949    read_bundle_workspace(root).unwrap_or_else(|_| {
950        let bundle_id = root
951            .file_name()
952            .and_then(|value| value.to_str())
953            .map(ToOwned::to_owned)
954            .filter(|value| !value.trim().is_empty())
955            .unwrap_or_else(|| "bundle".to_string());
956        BundleWorkspaceDefinition::new(
957            bundle_id.clone(),
958            bundle_id,
959            default_locale(),
960            default_mode(),
961        )
962    })
963}
964
965fn evaluate_app_pack_policies(
966    root: &Path,
967    tenant: &str,
968    team: Option<&str>,
969    app_packs: &[String],
970) -> Vec<ResolvedReferencePolicy> {
971    let tenant_rules =
972        crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
973            .unwrap_or_default();
974    let team_rules = team
975        .and_then(|team_name| {
976            crate::access::parse_file(
977                &root
978                    .join("tenants")
979                    .join(tenant)
980                    .join("teams")
981                    .join(team_name)
982                    .join("team.gmap"),
983            )
984            .ok()
985        })
986        .unwrap_or_default();
987
988    let mut entries = app_packs
989        .iter()
990        .map(|reference| {
991            let target = crate::access::GmapPath {
992                pack: Some(inferred_access_pack_id(reference)),
993                flow: None,
994                node: None,
995            };
996            let policy = if team.is_some() {
997                crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
998            } else {
999                crate::access::eval_policy(&tenant_rules, &target)
1000            };
1001            ResolvedReferencePolicy {
1002                reference: reference.clone(),
1003                policy: policy
1004                    .map(|decision| decision.policy.to_string())
1005                    .unwrap_or_else(|| "unset".to_string()),
1006            }
1007        })
1008        .collect::<Vec<_>>();
1009    entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1010    entries
1011}
1012
1013fn inferred_access_pack_id(reference: &str) -> String {
1014    let cleaned = reference
1015        .trim_end_matches('/')
1016        .rsplit('/')
1017        .next()
1018        .unwrap_or(reference)
1019        .split('@')
1020        .next()
1021        .unwrap_or(reference)
1022        .split(':')
1023        .next()
1024        .unwrap_or(reference)
1025        .trim_end_matches(".json")
1026        .trim_end_matches(".gtpack")
1027        .trim_end_matches(".yaml")
1028        .trim_end_matches(".yml");
1029    let mut normalized = String::with_capacity(cleaned.len());
1030    let mut last_dash = false;
1031    for ch in cleaned.chars() {
1032        let out = if ch.is_ascii_alphanumeric() {
1033            last_dash = false;
1034            ch.to_ascii_lowercase()
1035        } else if last_dash {
1036            continue;
1037        } else {
1038            last_dash = true;
1039            '-'
1040        };
1041        normalized.push(out);
1042    }
1043    normalized.trim_matches('-').to_string()
1044}
1045
1046fn inferred_provider_type(reference: &str) -> String {
1047    let raw = reference.trim();
1048    for marker in ["/providers/", "/packs/"] {
1049        if let Some((_, rest)) = raw.split_once(marker)
1050            && let Some(segment) = rest.split('/').next()
1051            && !segment.is_empty()
1052        {
1053            return segment.to_string();
1054        }
1055    }
1056
1057    let inferred = inferred_access_pack_id(reference);
1058    let mut parts = inferred.split('-');
1059    match (parts.next(), parts.next()) {
1060        (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1061        (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1062        (Some(_domain), None) => "other".to_string(),
1063        _ => "other".to_string(),
1064    }
1065}
1066
1067fn inferred_provider_filename(reference: &str) -> String {
1068    let cleaned = reference
1069        .trim_end_matches('/')
1070        .rsplit('/')
1071        .next()
1072        .unwrap_or(reference)
1073        .split('@')
1074        .next()
1075        .unwrap_or(reference)
1076        .split(':')
1077        .next()
1078        .unwrap_or(reference)
1079        .trim_end_matches(".gtpack");
1080    if cleaned.is_empty() {
1081        inferred_access_pack_id(reference)
1082    } else {
1083        cleaned.to_string()
1084    }
1085}
1086
1087fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1088    if values.is_empty() {
1089        vec![format!("{indent}[]")]
1090    } else {
1091        values
1092            .iter()
1093            .map(|value| format!("{indent}- {value}"))
1094            .collect()
1095    }
1096}
1097
1098fn relative_path(root: &Path, path: &Path) -> String {
1099    path.strip_prefix(root)
1100        .unwrap_or(path)
1101        .display()
1102        .to_string()
1103}
1104
1105fn ensure_dir(path: &Path) -> Result<()> {
1106    std::fs::create_dir_all(path)?;
1107    Ok(())
1108}
1109
1110fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1111    if path.exists() {
1112        return Ok(());
1113    }
1114    if let Some(parent) = path.parent() {
1115        ensure_dir(parent)?;
1116    }
1117    std::fs::write(path, contents)?;
1118    Ok(())
1119}
1120
1121/// Extracts `assets/webchat-gui/` entries from all provider `.gtpack` files into
1122/// the bundle root so users can see and directly modify skins, config, and other
1123/// webchat-gui assets. Other internal pack assets (fixtures, schemas,
1124/// secret-requirements, etc.) are intentionally excluded. Existing files are
1125/// never overwritten — user customizations are preserved.
1126pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1127    let mut written = Vec::new();
1128    let providers_dir = root.join("providers");
1129    if !providers_dir.is_dir() {
1130        return Ok(written);
1131    }
1132    for dir_entry in collect_gtpack_files(&providers_dir)? {
1133        match extract_pack_assets(root, &dir_entry) {
1134            Ok(paths) => written.extend(paths),
1135            Err(err) => {
1136                eprintln!(
1137                    "Warning: could not scaffold assets from {}: {err}",
1138                    dir_entry.display()
1139                );
1140            }
1141        }
1142    }
1143    Ok(written)
1144}
1145
1146fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1147    let mut files = Vec::new();
1148    for entry in std::fs::read_dir(dir)? {
1149        let entry = entry?;
1150        let path = entry.path();
1151        if path.is_dir() {
1152            files.extend(collect_gtpack_files(&path)?);
1153        } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1154            files.push(path);
1155        }
1156    }
1157    Ok(files)
1158}
1159
1160fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1161    let file =
1162        std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1163    let mut archive =
1164        zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1165    let mut written = Vec::new();
1166    for i in 0..archive.len() {
1167        let mut entry = archive.by_index(i)?;
1168        let name = entry.name().to_string();
1169        if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1170            continue;
1171        }
1172        let target = root.join(&name);
1173        if target.exists() {
1174            continue;
1175        }
1176        if let Some(parent) = target.parent() {
1177            std::fs::create_dir_all(parent)?;
1178        }
1179        let mut out = std::fs::File::create(&target)?;
1180        std::io::copy(&mut entry, &mut out)?;
1181        written.push(target);
1182    }
1183    Ok(written)
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::should_skip_extension_provider_materialization;
1189
1190    #[test]
1191    fn bundled_catalog_mode_skips_https_provider_materialization() {
1192        unsafe {
1193            std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1194        }
1195        assert!(should_skip_extension_provider_materialization(
1196            "https://example.com/providers/events-webhook.gtpack"
1197        ));
1198        unsafe {
1199            std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1200        }
1201    }
1202}