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