Skip to main content

greentic_bundle/project/
mod.rs

1pub mod agent_wiring;
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use greentic_distributor_client::{
8    CachePolicy, DistClient, DistOptions, OciPackFetcher, PackFetchOptions, ResolvePolicy,
9    oci_packs::DefaultRegistryClient,
10};
11use serde::{Deserialize, Serialize};
12use tokio::runtime::Runtime;
13
14pub const WORKSPACE_ROOT_FILE: &str = "bundle.yaml";
15pub const LOCK_FILE: &str = "bundle.lock.json";
16pub const LOCK_SCHEMA_VERSION: u32 = 1;
17
18const DEFAULT_GMAP: &str = "_ = forbidden\n";
19const GREENTIC_GTPACK_TAR_MEDIA_TYPE: &str = "application/vnd.greentic.gtpack.layer.v1+tar";
20const GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE: &str =
21    "application/vnd.greentic.gtpack.layer.v1.tar+gzip";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BundleWorkspaceDefinition {
25    #[serde(default = "default_schema_version")]
26    pub schema_version: u32,
27    pub bundle_id: String,
28    pub bundle_name: String,
29    #[serde(default = "default_locale")]
30    pub locale: String,
31    #[serde(default = "default_mode")]
32    pub mode: String,
33    #[serde(default)]
34    pub advanced_setup: bool,
35    #[serde(default)]
36    pub app_packs: Vec<String>,
37    /// Maps a runtime `agent_id` (`dw.agent` `operation`) to a pack coordinate
38    /// (`store://<name>@<version>` or `file://<path>`) so referenced agentic
39    /// workers can be auto-wired into the bundle at build/deploy time.
40    #[serde(default)]
41    pub agent_packs: BTreeMap<String, String>,
42    #[serde(default)]
43    pub app_pack_mappings: Vec<AppPackMapping>,
44    #[serde(default)]
45    pub extension_providers: Vec<String>,
46    #[serde(default)]
47    pub remote_catalogs: Vec<String>,
48    #[serde(default)]
49    pub hooks: Vec<String>,
50    #[serde(default)]
51    pub subscriptions: Vec<String>,
52    #[serde(default)]
53    pub capabilities: Vec<String>,
54    #[serde(default)]
55    pub setup_execution_intent: bool,
56    #[serde(default)]
57    pub export_intent: bool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct AppPackMapping {
62    pub reference: String,
63    pub scope: MappingScope,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub tenant: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub team: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MappingScope {
73    Global,
74    Tenant,
75    Team,
76}
77
78#[derive(Debug, Serialize)]
79struct ResolvedManifest {
80    version: String,
81    tenant: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    team: Option<String>,
84    project_root: String,
85    bundle: BundleSummary,
86    policy: PolicySection,
87    catalogs: Vec<String>,
88    app_packs: Vec<ResolvedReferencePolicy>,
89    extension_providers: Vec<String>,
90    hooks: Vec<String>,
91    subscriptions: Vec<String>,
92    capabilities: Vec<String>,
93}
94
95#[derive(Debug, Serialize)]
96struct BundleSummary {
97    bundle_id: String,
98    bundle_name: String,
99    locale: String,
100    mode: String,
101    advanced_setup: bool,
102    setup_execution_intent: bool,
103    export_intent: bool,
104}
105
106#[derive(Debug, Serialize)]
107struct PolicySection {
108    source: PolicySource,
109    default: String,
110}
111
112#[derive(Debug, Serialize)]
113struct PolicySource {
114    tenant_gmap: String,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    team_gmap: Option<String>,
117}
118
119#[derive(Debug, Serialize)]
120struct ResolvedReferencePolicy {
121    reference: String,
122    policy: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct BundleLock {
127    pub schema_version: u32,
128    pub bundle_id: String,
129    /// Environment id the wizard ran under (C7). `None` for locks emitted by
130    /// `empty_bundle_lock` (workspace scaffold, before any wizard run); set to
131    /// `Some(env)` once the wizard's `execute_request` materializes it. Read
132    /// by downstream readers that need to know which env the bundled
133    /// `setup_state_files` were minted under.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub env_id: Option<String>,
136    pub requested_mode: String,
137    pub execution: String,
138    pub cache_policy: String,
139    pub tool_version: String,
140    pub build_format_version: String,
141    pub workspace_root: String,
142    pub lock_file: String,
143    pub catalogs: Vec<crate::catalog::resolve::CatalogLockEntry>,
144    pub app_packs: Vec<DependencyLock>,
145    pub extension_providers: Vec<DependencyLock>,
146    pub setup_state_files: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct DependencyLock {
151    pub reference: String,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub digest: Option<String>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ReferenceField {
158    AppPack,
159    ExtensionProvider,
160}
161
162impl BundleWorkspaceDefinition {
163    pub fn new(bundle_name: String, bundle_id: String, locale: String, mode: String) -> Self {
164        Self {
165            schema_version: default_schema_version(),
166            bundle_id,
167            bundle_name,
168            locale,
169            mode,
170            advanced_setup: false,
171            app_packs: Vec::new(),
172            agent_packs: BTreeMap::new(),
173            app_pack_mappings: Vec::new(),
174            extension_providers: Vec::new(),
175            remote_catalogs: Vec::new(),
176            hooks: Vec::new(),
177            subscriptions: Vec::new(),
178            capabilities: Vec::new(),
179            setup_execution_intent: false,
180            export_intent: false,
181        }
182    }
183
184    pub fn canonicalize(&mut self) {
185        canonicalize_mappings(&mut self.app_pack_mappings);
186        self.app_packs.extend(
187            self.app_pack_mappings
188                .iter()
189                .map(|entry| entry.reference.clone()),
190        );
191        sort_unique(&mut self.app_packs);
192        sort_unique(&mut self.extension_providers);
193        sort_unique(&mut self.remote_catalogs);
194        sort_unique(&mut self.hooks);
195        sort_unique(&mut self.subscriptions);
196        sort_unique(&mut self.capabilities);
197    }
198
199    pub fn references(&self, field: ReferenceField) -> &[String] {
200        match field {
201            ReferenceField::AppPack => &self.app_packs,
202            ReferenceField::ExtensionProvider => &self.extension_providers,
203        }
204    }
205
206    pub fn references_mut(&mut self, field: ReferenceField) -> &mut Vec<String> {
207        match field {
208            ReferenceField::AppPack => &mut self.app_packs,
209            ReferenceField::ExtensionProvider => &mut self.extension_providers,
210        }
211    }
212}
213
214pub fn ensure_layout(root: &Path) -> Result<()> {
215    ensure_dir(&root.join("tenants"))?;
216    ensure_dir(&root.join("tenants").join("default"))?;
217    ensure_dir(&root.join("tenants").join("default").join("teams"))?;
218    ensure_dir(&root.join("resolved"))?;
219    ensure_dir(&root.join("state").join("resolved"))?;
220    write_if_missing(&root.join(WORKSPACE_ROOT_FILE), "schema_version: 1\n")?;
221    write_if_missing(
222        &root.join("tenants").join("default").join("tenant.gmap"),
223        DEFAULT_GMAP,
224    )?;
225    Ok(())
226}
227
228/// Bundle-level capability for read-only asset access by packs.
229pub const CAP_BUNDLE_ASSETS_READ_V1: &str = "greentic.cap.bundle_assets.read.v1";
230
231/// Creates the `assets/` directory at the bundle root for bundle-level shared assets.
232pub fn ensure_assets_dir(root: &Path) -> Result<()> {
233    ensure_dir(&root.join("assets"))
234}
235
236pub fn read_bundle_workspace(root: &Path) -> Result<BundleWorkspaceDefinition> {
237    let raw = std::fs::read_to_string(root.join(WORKSPACE_ROOT_FILE))?;
238    let mut definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&raw)?;
239    definition.canonicalize();
240    Ok(definition)
241}
242
243pub fn write_bundle_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
244    let mut workspace = workspace.clone();
245    workspace.canonicalize();
246    let path = root.join(WORKSPACE_ROOT_FILE);
247    if let Some(parent) = path.parent() {
248        ensure_dir(parent)?;
249    }
250    std::fs::write(path, render_bundle_workspace(&workspace))?;
251    Ok(())
252}
253
254pub fn init_bundle_workspace(
255    root: &Path,
256    workspace: &BundleWorkspaceDefinition,
257) -> Result<Vec<PathBuf>> {
258    ensure_layout(root)?;
259    let has_bundle_assets = workspace
260        .capabilities
261        .iter()
262        .any(|c| c == CAP_BUNDLE_ASSETS_READ_V1);
263    if has_bundle_assets {
264        ensure_assets_dir(root)?;
265    }
266    write_bundle_workspace(root, workspace)?;
267    let lock = empty_bundle_lock(workspace);
268    write_bundle_lock(root, &lock)?;
269    sync_project(root)?;
270    let mut files = vec![
271        root.join(WORKSPACE_ROOT_FILE),
272        root.join(LOCK_FILE),
273        root.join("tenants/default/tenant.gmap"),
274        root.join("resolved/default.yaml"),
275        root.join("state/resolved/default.yaml"),
276    ];
277    if has_bundle_assets {
278        files.push(root.join("assets"));
279    }
280    Ok(files)
281}
282
283pub fn sync_lock_with_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
284    let mut lock = if root.join(LOCK_FILE).exists() {
285        read_bundle_lock(root)?
286    } else {
287        empty_bundle_lock(workspace)
288    };
289    lock.bundle_id = workspace.bundle_id.clone();
290    lock.requested_mode = workspace.mode.clone();
291    lock.workspace_root = WORKSPACE_ROOT_FILE.to_string();
292    lock.lock_file = LOCK_FILE.to_string();
293    lock.app_packs = workspace
294        .app_packs
295        .iter()
296        .map(|reference| DependencyLock {
297            reference: reference.clone(),
298            digest: None,
299        })
300        .collect();
301    lock.extension_providers = workspace
302        .extension_providers
303        .iter()
304        .map(|reference| DependencyLock {
305            reference: reference.clone(),
306            digest: None,
307        })
308        .collect();
309    write_bundle_lock(root, &lock)
310}
311
312pub fn ensure_tenant(root: &Path, tenant: &str) -> Result<()> {
313    let tenant_dir = root.join("tenants").join(tenant);
314    ensure_dir(&tenant_dir.join("teams"))?;
315    write_if_missing(&tenant_dir.join("tenant.gmap"), DEFAULT_GMAP)?;
316    Ok(())
317}
318
319pub fn ensure_team(root: &Path, tenant: &str, team: &str) -> Result<()> {
320    ensure_tenant(root, tenant)?;
321    let team_dir = root.join("tenants").join(tenant).join("teams").join(team);
322    ensure_dir(&team_dir)?;
323    write_if_missing(&team_dir.join("team.gmap"), DEFAULT_GMAP)?;
324    Ok(())
325}
326
327pub fn gmap_path(root: &Path, target: &crate::access::GmapTarget) -> PathBuf {
328    if let Some(team) = &target.team {
329        root.join("tenants")
330            .join(&target.tenant)
331            .join("teams")
332            .join(team)
333            .join("team.gmap")
334    } else {
335        root.join("tenants")
336            .join(&target.tenant)
337            .join("tenant.gmap")
338    }
339}
340
341pub fn resolved_output_paths(root: &Path, tenant: &str, team: Option<&str>) -> Vec<PathBuf> {
342    let filename = match team {
343        Some(team) => format!("{tenant}.{team}.yaml"),
344        None => format!("{tenant}.yaml"),
345    };
346    vec![
347        root.join("resolved").join(&filename),
348        root.join("state").join("resolved").join(filename),
349    ]
350}
351
352pub fn sync_project(root: &Path) -> Result<()> {
353    sync_project_with_reference_roots(root, &[])
354}
355
356pub fn sync_project_with_reference_roots(root: &Path, reference_roots: &[PathBuf]) -> Result<()> {
357    ensure_layout(root)?;
358    if let Ok(workspace) = read_bundle_workspace(root) {
359        materialize_workspace_dependencies(root, &workspace, reference_roots)?;
360    }
361    for tenant in list_tenants(root)? {
362        let teams = list_teams(root, &tenant)?;
363        if teams.is_empty() {
364            let manifest = build_manifest(root, &tenant, None);
365            write_resolved_outputs(root, &tenant, None, &manifest)?;
366        } else {
367            let tenant_manifest = build_manifest(root, &tenant, None);
368            write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
369            for team in teams {
370                let manifest = build_manifest(root, &tenant, Some(&team));
371                write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
372            }
373        }
374    }
375    Ok(())
376}
377
378fn materialize_workspace_dependencies(
379    root: &Path,
380    workspace: &BundleWorkspaceDefinition,
381    reference_roots: &[PathBuf],
382) -> Result<()> {
383    let app_targets = app_pack_copy_targets(workspace);
384    let provider_targets: Vec<_> = workspace
385        .extension_providers
386        .iter()
387        .filter(|p| !should_skip_extension_provider_materialization(p))
388        .collect();
389    let total = app_targets.len() + provider_targets.len();
390    let mut current = 0usize;
391    let force_refresh = crate::runtime::refresh();
392
393    for mapping in &app_targets {
394        current += 1;
395        let dest = root.join(&mapping.destination);
396        if dest.exists() {
397            if force_refresh {
398                eprintln!(
399                    "  [{current}/{total}] Refreshing app pack: {}",
400                    mapping.reference
401                );
402            } else {
403                eprintln!(
404                    "  [{current}/{total}] Reused (local file exists): {}",
405                    mapping.reference
406                );
407            }
408        } else {
409            eprintln!(
410                "  [{current}/{total}] Resolving app pack: {}",
411                mapping.reference
412            );
413        }
414        materialize_reference_into(
415            root,
416            reference_roots,
417            &mapping.reference,
418            &mapping.destination,
419        )?;
420    }
421    for provider in &provider_targets {
422        current += 1;
423        let destination = provider_destination_path(provider);
424        let dest = root.join(&destination);
425        if dest.exists() {
426            if force_refresh {
427                eprintln!("  [{current}/{total}] Refreshing provider: {provider}");
428            } else {
429                eprintln!("  [{current}/{total}] Reused (local file exists): {provider}");
430            }
431        } else {
432            eprintln!("  [{current}/{total}] Resolving provider: {provider}");
433        }
434        materialize_reference_into(root, reference_roots, provider, &destination)?;
435    }
436    if total > 0 {
437        eprintln!("  [done] Resolved {total} package(s)");
438    }
439
440    // --- Agent-pack auto-wiring pass (SP2 Task 5) ----------------------------
441    // After all declared app_packs are materialised, scan them for dw.agent
442    // references that are not yet provided, and resolve any missing ones from
443    // the bundle's `agent_packs` coordinate map.
444    run_agent_pack_auto_wiring(root, workspace, &app_targets)?;
445
446    Ok(())
447}
448
449/// Read a named entry from a `.gtpack` ZIP by filesystem path.
450///
451/// Returns `None` when the entry is absent, the file cannot be opened, or the
452/// zip archive cannot be parsed.  Never panics.
453fn read_gtpack_entry(pack_path: &Path, entry_name: &str) -> Option<Vec<u8>> {
454    use std::io::Read;
455    let file = std::fs::File::open(pack_path).ok()?;
456    let mut archive = zip::ZipArchive::new(file).ok()?;
457    let mut entry = archive.by_name(entry_name).ok()?;
458    let mut buf = Vec::new();
459    entry.read_to_end(&mut buf).ok()?;
460    Some(buf)
461}
462
463/// Collect flow manifests and agent sidecars from a set of already-materialised
464/// app packs, then call `auto_wire_agent_packs` for any unreferenced agents.
465fn run_agent_pack_auto_wiring(
466    root: &Path,
467    workspace: &BundleWorkspaceDefinition,
468    app_targets: &[MaterializedCopyTarget],
469) -> Result<()> {
470    // Skip entirely when the workspace has no agent_packs mapping — nothing to
471    // wire, and we avoid zip-opening overhead for bundles that don't use agents.
472    if workspace.agent_packs.is_empty() {
473        return Ok(());
474    }
475
476    let mut flow_manifests: Vec<Vec<u8>> = Vec::new();
477    let mut provided_sidecars: Vec<Vec<u8>> = Vec::new();
478
479    for target in app_targets {
480        let pack_path = root.join(&target.destination);
481        if !pack_path.exists() {
482            continue;
483        }
484        if let Some(cbor) = read_gtpack_entry(&pack_path, "manifest.cbor") {
485            flow_manifests.push(cbor);
486        }
487        if let Some(sidecar) = read_gtpack_entry(&pack_path, "dw-agents.json") {
488            provided_sidecars.push(sidecar);
489        }
490    }
491
492    let manifest_refs: Vec<&[u8]> = flow_manifests.iter().map(Vec::as_slice).collect();
493    let sidecar_refs: Vec<&[u8]> = provided_sidecars.iter().map(Vec::as_slice).collect();
494
495    let packs_dir = root.join("packs");
496    let cache_dir = root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts");
497    // SP2 v1 deliberate choice: an empty TrustRoot = sha256-only verification.
498    // The Ed25519/DSSE chain is fully plumbed (the store emits a DSSE envelope
499    // pinning the artifact sha256; `fetch_store_agentic_worker_verified` checks
500    // it whenever the TrustRoot is non-empty) but kept DORMANT here on purpose:
501    // enforcement is a follow-up to be flipped on once the store serves the
502    // envelope in production and a trusted-publisher-key source is wired. A
503    // populated TrustRoot here would fail-closed every fetch until then.
504    let trust = greentic_distributor_client::signing::TrustRoot::default();
505
506    let materialized = agent_wiring::auto_wire_agent_packs(
507        workspace,
508        &manifest_refs,
509        &sidecar_refs,
510        &packs_dir,
511        &cache_dir,
512        crate::runtime::offline(),
513        &trust,
514    )?;
515
516    if !materialized.is_empty() {
517        eprintln!(
518            "  [agent-packs] Auto-wired {} agent pack(s): {}",
519            materialized.len(),
520            materialized.join(", ")
521        );
522    }
523    Ok(())
524}
525
526fn should_skip_extension_provider_materialization(reference: &str) -> bool {
527    bundled_catalog_mode()
528        && (reference.starts_with("oci://")
529            || reference.starts_with("repo://")
530            || reference.starts_with("store://")
531            || reference.starts_with("https://"))
532}
533
534fn bundled_catalog_mode() -> bool {
535    std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
536        .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
537        .unwrap_or(false)
538}
539
540struct MaterializedCopyTarget {
541    reference: String,
542    destination: PathBuf,
543}
544
545fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
546    if workspace.app_pack_mappings.is_empty() {
547        return workspace
548            .app_packs
549            .iter()
550            .map(|reference| MaterializedCopyTarget {
551                reference: reference.clone(),
552                destination: PathBuf::from("packs")
553                    .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
554            })
555            .collect();
556    }
557
558    workspace
559        .app_pack_mappings
560        .iter()
561        .map(|mapping| {
562            let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
563            let destination = match mapping.scope {
564                MappingScope::Global => PathBuf::from("packs").join(filename),
565                MappingScope::Tenant => PathBuf::from("tenants")
566                    .join(mapping.tenant.as_deref().unwrap_or("default"))
567                    .join("packs")
568                    .join(filename),
569                MappingScope::Team => PathBuf::from("tenants")
570                    .join(mapping.tenant.as_deref().unwrap_or("default"))
571                    .join("teams")
572                    .join(mapping.team.as_deref().unwrap_or("default"))
573                    .join("packs")
574                    .join(filename),
575            };
576            MaterializedCopyTarget {
577                reference: mapping.reference.clone(),
578                destination,
579            }
580        })
581        .collect()
582}
583
584fn provider_destination_path(reference: &str) -> PathBuf {
585    let provider_type = inferred_provider_type(reference);
586    let provider_name = inferred_provider_filename(reference);
587    PathBuf::from("providers")
588        .join(provider_type)
589        .join(format!("{provider_name}.gtpack"))
590}
591
592fn materialize_reference_into(
593    root: &Path,
594    reference_roots: &[PathBuf],
595    reference: &str,
596    relative_destination: &Path,
597) -> Result<()> {
598    let destination = root.join(relative_destination);
599    if destination.exists() {
600        if !crate::runtime::refresh() {
601            return Ok(());
602        }
603        std::fs::remove_file(&destination)
604            .with_context(|| format!("remove existing {} before refresh", destination.display()))?;
605    }
606    if let Some(parent) = destination.parent() {
607        ensure_dir(parent)?;
608    }
609
610    if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
611        if local_path.is_dir() {
612            return Ok(());
613        }
614        std::fs::copy(&local_path, &destination).with_context(|| {
615            format!("copy {} to {}", local_path.display(), destination.display())
616        })?;
617        return Ok(());
618    }
619
620    if !(reference.starts_with("oci://")
621        || reference.starts_with("repo://")
622        || reference.starts_with("store://")
623        || reference.starts_with("https://"))
624    {
625        return Ok(());
626    }
627
628    let path = resolve_remote_pack_path(root, reference)?;
629    std::fs::copy(&path, &destination)
630        .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
631
632    Ok(())
633}
634
635fn parse_local_pack_reference(
636    root: &Path,
637    reference_roots: &[PathBuf],
638    reference: &str,
639) -> Option<PathBuf> {
640    if let Some(path) = reference.strip_prefix("file://") {
641        let path = PathBuf::from(path.trim());
642        if path.is_absolute() {
643            return path.exists().then_some(path);
644        }
645        for base in reference_roots
646            .iter()
647            .map(PathBuf::as_path)
648            .chain(std::iter::once(root))
649        {
650            let candidate = base.join(&path);
651            if candidate.exists() {
652                return Some(candidate);
653            }
654        }
655        return None;
656    }
657    if reference.contains("://") {
658        return None;
659    }
660    let candidate = PathBuf::from(reference);
661    if candidate.is_absolute() {
662        return candidate.exists().then_some(candidate);
663    }
664    for base in reference_roots
665        .iter()
666        .map(PathBuf::as_path)
667        .chain(std::iter::once(root))
668    {
669        let joined = base.join(&candidate);
670        if joined.exists() {
671            return Some(joined);
672        }
673    }
674    None
675}
676
677fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
678    if let Some(oci_reference) = reference.strip_prefix("oci://") {
679        let mut options = PackFetchOptions {
680            allow_tags: true,
681            offline: crate::runtime::offline(),
682            cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
683            ..PackFetchOptions::default()
684        };
685        options.accepted_layer_media_types.extend([
686            GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
687            GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
688        ]);
689        options.preferred_layer_media_types.splice(
690            0..0,
691            [
692                GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
693                GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
694            ],
695        );
696        let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
697        let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
698        let resolved = runtime
699            .block_on(fetcher.fetch_pack_to_cache(oci_reference))
700            .with_context(|| format!("resolve OCI pack ref {reference}"))?;
701        return Ok(resolved.path);
702    }
703
704    let options = DistOptions {
705        allow_tags: true,
706        offline: crate::runtime::offline(),
707        cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
708        ..DistOptions::default()
709    };
710    let client = DistClient::new(options);
711    let runtime = Runtime::new().context("create artifact resolver runtime")?;
712    let source = client
713        .parse_source(reference)
714        .with_context(|| format!("parse artifact ref {reference}"))?;
715    let descriptor = runtime
716        .block_on(client.resolve(source, ResolvePolicy))
717        .with_context(|| format!("resolve artifact ref {reference}"))?;
718    let resolved = runtime
719        .block_on(client.fetch(&descriptor, CachePolicy))
720        .with_context(|| format!("fetch artifact ref {reference}"))?;
721    if let Some(path) = resolved.wasm_path {
722        return Ok(path);
723    }
724    if let Some(bytes) = resolved.wasm_bytes {
725        let digest = resolved.resolved_digest.trim_start_matches("sha256:");
726        let temp_path = root
727            .join(crate::catalog::CACHE_ROOT_DIR)
728            .join("artifacts")
729            .join("inline")
730            .join(format!("{digest}.gtpack"));
731        if let Some(parent) = temp_path.parent() {
732            ensure_dir(parent)?;
733        }
734        std::fs::write(&temp_path, bytes)
735            .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
736        return Ok(temp_path);
737    }
738    anyhow::bail!("artifact ref {reference} resolved without file payload");
739}
740
741pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
742    let tenants_dir = root.join("tenants");
743    let mut tenants = Vec::new();
744    if !tenants_dir.exists() {
745        return Ok(tenants);
746    }
747    for entry in std::fs::read_dir(tenants_dir)? {
748        let entry = entry?;
749        if entry.file_type()?.is_dir() {
750            tenants.push(entry.file_name().to_string_lossy().to_string());
751        }
752    }
753    tenants.sort();
754    Ok(tenants)
755}
756
757pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
758    let teams_dir = root.join("tenants").join(tenant).join("teams");
759    let mut teams = Vec::new();
760    if !teams_dir.exists() {
761        return Ok(teams);
762    }
763    for entry in std::fs::read_dir(teams_dir)? {
764        let entry = entry?;
765        if entry.file_type()?.is_dir() {
766            teams.push(entry.file_name().to_string_lossy().to_string());
767        }
768    }
769    teams.sort();
770    Ok(teams)
771}
772
773pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
774    let path = root.join(LOCK_FILE);
775    if let Some(parent) = path.parent() {
776        ensure_dir(parent)?;
777    }
778    std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
779    Ok(())
780}
781
782pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
783    let path = root.join(LOCK_FILE);
784    let raw = std::fs::read_to_string(&path)?;
785    Ok(serde_json::from_str(&raw)?)
786}
787
788fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
789    let workspace = read_workspace_or_default(root);
790    let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
791    let team_gmap = team.map(|team| {
792        relative_path(
793            root,
794            &root
795                .join("tenants")
796                .join(tenant)
797                .join("teams")
798                .join(team)
799                .join("team.gmap"),
800        )
801    });
802
803    let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
804
805    ResolvedManifest {
806        version: "1".to_string(),
807        tenant: tenant.to_string(),
808        team: team.map(ToOwned::to_owned),
809        project_root: root.display().to_string(),
810        bundle: BundleSummary {
811            bundle_id: workspace.bundle_id,
812            bundle_name: workspace.bundle_name,
813            locale: workspace.locale,
814            mode: workspace.mode,
815            advanced_setup: workspace.advanced_setup,
816            setup_execution_intent: workspace.setup_execution_intent,
817            export_intent: workspace.export_intent,
818        },
819        policy: PolicySection {
820            source: PolicySource {
821                tenant_gmap,
822                team_gmap,
823            },
824            default: "forbidden".to_string(),
825        },
826        catalogs: workspace.remote_catalogs,
827        app_packs,
828        extension_providers: workspace.extension_providers,
829        hooks: workspace.hooks,
830        subscriptions: workspace.subscriptions,
831        capabilities: workspace.capabilities,
832    }
833}
834
835fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
836    // NOTE: keep this format! in lockstep with `BundleWorkspaceDefinition`; every
837    // field must be emitted so a parse→render→re-parse round-trip is lossless.
838    format!(
839        concat!(
840            "schema_version: {}\n",
841            "bundle_id: {}\n",
842            "bundle_name: {}\n",
843            "locale: {}\n",
844            "mode: {}\n",
845            "advanced_setup: {}\n",
846            "agent_packs:{}\n",
847            "app_packs:{}\n",
848            "app_pack_mappings:{}\n",
849            "extension_providers:{}\n",
850            "remote_catalogs:{}\n",
851            "hooks:{}\n",
852            "subscriptions:{}\n",
853            "capabilities:{}\n",
854            "setup_execution_intent: {}\n",
855            "export_intent: {}\n"
856        ),
857        workspace.schema_version,
858        workspace.bundle_id,
859        workspace.bundle_name,
860        workspace.locale,
861        workspace.mode,
862        workspace.advanced_setup,
863        yaml_sorted_string_map(&workspace.agent_packs),
864        yaml_list(&workspace.app_packs),
865        yaml_mapping_list(&workspace.app_pack_mappings),
866        yaml_list(&workspace.extension_providers),
867        yaml_list(&workspace.remote_catalogs),
868        yaml_list(&workspace.hooks),
869        yaml_list(&workspace.subscriptions),
870        yaml_list(&workspace.capabilities),
871        workspace.setup_execution_intent,
872        workspace.export_intent
873    )
874}
875
876fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
877    if values.is_empty() {
878        " []".to_string()
879    } else {
880        values
881            .iter()
882            .map(|value| {
883                let mut out = format!(
884                    "\n  - reference: {}\n    scope: {}",
885                    value.reference,
886                    match value.scope {
887                        MappingScope::Global => "global",
888                        MappingScope::Tenant => "tenant",
889                        MappingScope::Team => "team",
890                    }
891                );
892                if let Some(tenant) = &value.tenant {
893                    out.push_str(&format!("\n    tenant: {tenant}"));
894                }
895                if let Some(team) = &value.team {
896                    out.push_str(&format!("\n    team: {team}"));
897                }
898                out
899            })
900            .collect::<String>()
901    }
902}
903
904fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
905    BundleLock {
906        schema_version: LOCK_SCHEMA_VERSION,
907        bundle_id: workspace.bundle_id.clone(),
908        env_id: None,
909        requested_mode: workspace.mode.clone(),
910        execution: "execute".to_string(),
911        cache_policy: "workspace-local".to_string(),
912        tool_version: env!("CARGO_PKG_VERSION").to_string(),
913        build_format_version: "bundle-lock-v1".to_string(),
914        workspace_root: WORKSPACE_ROOT_FILE.to_string(),
915        lock_file: LOCK_FILE.to_string(),
916        catalogs: Vec::new(),
917        app_packs: workspace
918            .app_packs
919            .iter()
920            .map(|reference| DependencyLock {
921                reference: reference.clone(),
922                digest: None,
923            })
924            .collect(),
925        extension_providers: workspace
926            .extension_providers
927            .iter()
928            .map(|reference| DependencyLock {
929                reference: reference.clone(),
930                digest: None,
931            })
932            .collect(),
933        setup_state_files: Vec::new(),
934    }
935}
936
937fn yaml_list(values: &[String]) -> String {
938    if values.is_empty() {
939        " []".to_string()
940    } else {
941        values
942            .iter()
943            .map(|value| format!("\n  - {value}"))
944            .collect::<String>()
945    }
946}
947
948/// Serialize a `BTreeMap<String, String>` as a YAML block mapping.
949///
950/// An empty map emits ` {}`.  Non-empty entries are sorted by key (BTreeMap
951/// guarantees this already) and emitted as `\n  <key>: "<value>"`.  String
952/// values are always quoted to handle values that contain YAML-special characters
953/// (colons, slashes, etc.).
954fn yaml_sorted_string_map(map: &BTreeMap<String, String>) -> String {
955    if map.is_empty() {
956        return " {}".to_string();
957    }
958    map.iter()
959        .map(|(key, value)| format!("\n  {key}: \"{value}\""))
960        .collect()
961}
962
963fn sort_unique(values: &mut Vec<String>) {
964    values.retain(|value| !value.trim().is_empty());
965    values.sort();
966    values.dedup();
967}
968
969fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
970    values.retain(|value| !value.reference.trim().is_empty());
971    for value in values.iter_mut() {
972        if value
973            .tenant
974            .as_deref()
975            .is_some_and(|tenant| tenant.trim().is_empty())
976        {
977            value.tenant = None;
978        }
979        if value
980            .team
981            .as_deref()
982            .is_some_and(|team| team.trim().is_empty())
983        {
984            value.team = None;
985        }
986        if matches!(value.scope, MappingScope::Global) {
987            value.tenant = None;
988            value.team = None;
989        } else if matches!(value.scope, MappingScope::Tenant) {
990            value.team = None;
991        }
992    }
993    values.sort_by(|left, right| {
994        left.reference
995            .cmp(&right.reference)
996            .then(left.scope.cmp(&right.scope))
997            .then(left.tenant.cmp(&right.tenant))
998            .then(left.team.cmp(&right.team))
999    });
1000    values.dedup_by(|left, right| {
1001        left.reference == right.reference
1002            && left.scope == right.scope
1003            && left.tenant == right.tenant
1004            && left.team == right.team
1005    });
1006}
1007
1008fn default_schema_version() -> u32 {
1009    1
1010}
1011
1012fn default_locale() -> String {
1013    "en".to_string()
1014}
1015
1016fn default_mode() -> String {
1017    "create".to_string()
1018}
1019
1020fn write_resolved_outputs(
1021    root: &Path,
1022    tenant: &str,
1023    team: Option<&str>,
1024    manifest: &ResolvedManifest,
1025) -> Result<()> {
1026    let yaml = render_manifest_yaml(manifest);
1027    for output in resolved_output_paths(root, tenant, team) {
1028        if let Some(parent) = output.parent() {
1029            ensure_dir(parent)?;
1030        }
1031        std::fs::write(output, &yaml)?;
1032    }
1033    Ok(())
1034}
1035
1036fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
1037    let mut lines = vec![
1038        format!("version: {}", manifest.version),
1039        format!("tenant: {}", manifest.tenant),
1040    ];
1041    if let Some(team) = &manifest.team {
1042        lines.push(format!("team: {}", team));
1043    }
1044    lines.extend([
1045        format!("project_root: {}", manifest.project_root),
1046        "bundle:".to_string(),
1047        format!("  bundle_id: {}", manifest.bundle.bundle_id),
1048        format!("  bundle_name: {}", manifest.bundle.bundle_name),
1049        format!("  locale: {}", manifest.bundle.locale),
1050        format!("  mode: {}", manifest.bundle.mode),
1051        format!("  advanced_setup: {}", manifest.bundle.advanced_setup),
1052        format!(
1053            "  setup_execution_intent: {}",
1054            manifest.bundle.setup_execution_intent
1055        ),
1056        format!("  export_intent: {}", manifest.bundle.export_intent),
1057        "policy:".to_string(),
1058        "  source:".to_string(),
1059        format!("    tenant_gmap: {}", manifest.policy.source.tenant_gmap),
1060    ]);
1061    if let Some(team_gmap) = &manifest.policy.source.team_gmap {
1062        lines.push(format!("    team_gmap: {}", team_gmap));
1063    }
1064    lines.push(format!("  default: {}", manifest.policy.default));
1065    lines.push("catalogs:".to_string());
1066    lines.extend(render_yaml_list("  ", &manifest.catalogs));
1067    lines.push("app_packs:".to_string());
1068    if manifest.app_packs.is_empty() {
1069        lines.push("  []".to_string());
1070    } else {
1071        for entry in &manifest.app_packs {
1072            lines.push(format!("  - reference: {}", entry.reference));
1073            lines.push(format!("    policy: {}", entry.policy));
1074        }
1075    }
1076    lines.push("extension_providers:".to_string());
1077    lines.extend(render_yaml_list("  ", &manifest.extension_providers));
1078    lines.push("hooks:".to_string());
1079    lines.extend(render_yaml_list("  ", &manifest.hooks));
1080    lines.push("subscriptions:".to_string());
1081    lines.extend(render_yaml_list("  ", &manifest.subscriptions));
1082    lines.push("capabilities:".to_string());
1083    lines.extend(render_yaml_list("  ", &manifest.capabilities));
1084    format!("{}\n", lines.join("\n"))
1085}
1086
1087fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
1088    read_bundle_workspace(root).unwrap_or_else(|_| {
1089        let bundle_id = root
1090            .file_name()
1091            .and_then(|value| value.to_str())
1092            .map(ToOwned::to_owned)
1093            .filter(|value| !value.trim().is_empty())
1094            .unwrap_or_else(|| "bundle".to_string());
1095        BundleWorkspaceDefinition::new(
1096            bundle_id.clone(),
1097            bundle_id,
1098            default_locale(),
1099            default_mode(),
1100        )
1101    })
1102}
1103
1104fn evaluate_app_pack_policies(
1105    root: &Path,
1106    tenant: &str,
1107    team: Option<&str>,
1108    app_packs: &[String],
1109) -> Vec<ResolvedReferencePolicy> {
1110    let tenant_rules =
1111        crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
1112            .unwrap_or_default();
1113    let team_rules = team
1114        .and_then(|team_name| {
1115            crate::access::parse_file(
1116                &root
1117                    .join("tenants")
1118                    .join(tenant)
1119                    .join("teams")
1120                    .join(team_name)
1121                    .join("team.gmap"),
1122            )
1123            .ok()
1124        })
1125        .unwrap_or_default();
1126
1127    let mut entries = app_packs
1128        .iter()
1129        .map(|reference| {
1130            let target = crate::access::GmapPath {
1131                pack: Some(inferred_access_pack_id(reference)),
1132                flow: None,
1133                node: None,
1134            };
1135            let policy = if team.is_some() {
1136                crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
1137            } else {
1138                crate::access::eval_policy(&tenant_rules, &target)
1139            };
1140            ResolvedReferencePolicy {
1141                reference: reference.clone(),
1142                policy: policy
1143                    .map(|decision| decision.policy.to_string())
1144                    .unwrap_or_else(|| "unset".to_string()),
1145            }
1146        })
1147        .collect::<Vec<_>>();
1148    entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1149    entries
1150}
1151
1152fn inferred_access_pack_id(reference: &str) -> String {
1153    let cleaned = reference
1154        .trim_end_matches('/')
1155        .rsplit('/')
1156        .next()
1157        .unwrap_or(reference)
1158        .split('@')
1159        .next()
1160        .unwrap_or(reference)
1161        .split(':')
1162        .next()
1163        .unwrap_or(reference)
1164        .trim_end_matches(".json")
1165        .trim_end_matches(".gtpack")
1166        .trim_end_matches(".yaml")
1167        .trim_end_matches(".yml");
1168    let mut normalized = String::with_capacity(cleaned.len());
1169    let mut last_dash = false;
1170    for ch in cleaned.chars() {
1171        let out = if ch.is_ascii_alphanumeric() {
1172            last_dash = false;
1173            ch.to_ascii_lowercase()
1174        } else if last_dash {
1175            continue;
1176        } else {
1177            last_dash = true;
1178            '-'
1179        };
1180        normalized.push(out);
1181    }
1182    normalized.trim_matches('-').to_string()
1183}
1184
1185fn inferred_provider_type(reference: &str) -> String {
1186    let raw = reference.trim();
1187    for marker in ["/providers/", "/packs/"] {
1188        if let Some((_, rest)) = raw.split_once(marker)
1189            && let Some(segment) = rest.split('/').next()
1190            && !segment.is_empty()
1191        {
1192            return segment.to_string();
1193        }
1194    }
1195
1196    let inferred = inferred_access_pack_id(reference);
1197    let mut parts = inferred.split('-');
1198    match (parts.next(), parts.next()) {
1199        (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1200        (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1201        (Some(_domain), None) => "other".to_string(),
1202        _ => "other".to_string(),
1203    }
1204}
1205
1206fn inferred_provider_filename(reference: &str) -> String {
1207    let cleaned = reference
1208        .trim_end_matches('/')
1209        .rsplit('/')
1210        .next()
1211        .unwrap_or(reference)
1212        .split('@')
1213        .next()
1214        .unwrap_or(reference)
1215        .split(':')
1216        .next()
1217        .unwrap_or(reference)
1218        .trim_end_matches(".gtpack");
1219    if let Some(deployer_target) = cleaned.strip_prefix("greentic.deploy.")
1220        && !deployer_target.trim().is_empty()
1221    {
1222        return deployer_target.trim().to_string();
1223    }
1224    if cleaned.is_empty() {
1225        inferred_access_pack_id(reference)
1226    } else {
1227        cleaned.to_string()
1228    }
1229}
1230
1231fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1232    if values.is_empty() {
1233        vec![format!("{indent}[]")]
1234    } else {
1235        values
1236            .iter()
1237            .map(|value| format!("{indent}- {value}"))
1238            .collect()
1239    }
1240}
1241
1242fn relative_path(root: &Path, path: &Path) -> String {
1243    path.strip_prefix(root)
1244        .unwrap_or(path)
1245        .display()
1246        .to_string()
1247}
1248
1249fn ensure_dir(path: &Path) -> Result<()> {
1250    std::fs::create_dir_all(path)?;
1251    Ok(())
1252}
1253
1254fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1255    if path.exists() {
1256        return Ok(());
1257    }
1258    if let Some(parent) = path.parent() {
1259        ensure_dir(parent)?;
1260    }
1261    std::fs::write(path, contents)?;
1262    Ok(())
1263}
1264
1265/// Extracts `assets/webchat-gui/` entries from all provider `.gtpack` files into
1266/// the bundle root so users can see and directly modify skins, config, and other
1267/// webchat-gui assets. Other internal pack assets (fixtures, schemas,
1268/// secret-requirements, etc.) are intentionally excluded. Existing files are
1269/// never overwritten — user customizations are preserved.
1270pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1271    let mut written = Vec::new();
1272    let providers_dir = root.join("providers");
1273    if !providers_dir.is_dir() {
1274        return Ok(written);
1275    }
1276    for dir_entry in collect_gtpack_files(&providers_dir)? {
1277        match extract_pack_assets(root, &dir_entry) {
1278            Ok(paths) => written.extend(paths),
1279            Err(err) => {
1280                eprintln!(
1281                    "Warning: could not scaffold assets from {}: {err}",
1282                    dir_entry.display()
1283                );
1284            }
1285        }
1286    }
1287    Ok(written)
1288}
1289
1290fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1291    let mut files = Vec::new();
1292    for entry in std::fs::read_dir(dir)? {
1293        let entry = entry?;
1294        let path = entry.path();
1295        if path.is_dir() {
1296            files.extend(collect_gtpack_files(&path)?);
1297        } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1298            files.push(path);
1299        }
1300    }
1301    Ok(files)
1302}
1303
1304fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1305    let file =
1306        std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1307    let mut archive =
1308        zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1309    let mut written = Vec::new();
1310    for i in 0..archive.len() {
1311        let mut entry = archive.by_index(i)?;
1312        let name = entry.name().to_string();
1313        if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1314            continue;
1315        }
1316        let target = root.join(&name);
1317        if target.exists() {
1318            continue;
1319        }
1320        if let Some(parent) = target.parent() {
1321            std::fs::create_dir_all(parent)?;
1322        }
1323        let mut out = std::fs::File::create(&target)?;
1324        std::io::copy(&mut entry, &mut out)?;
1325        written.push(target);
1326    }
1327    Ok(written)
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use std::path::PathBuf;
1333
1334    use super::BundleWorkspaceDefinition;
1335    use super::{provider_destination_path, should_skip_extension_provider_materialization};
1336
1337    #[test]
1338    fn agent_packs_parses_into_map() {
1339        let raw = concat!(
1340            "schema_version: 1\n",
1341            "bundle_id: demo\n",
1342            "bundle_name: Demo Bundle\n",
1343            "agent_packs:\n",
1344            "  tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1345        );
1346        let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1347            .expect("config with agent_packs should parse");
1348        assert_eq!(
1349            definition
1350                .agent_packs
1351                .get("tavily_researcher")
1352                .map(String::as_str),
1353            Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1354        );
1355    }
1356
1357    #[test]
1358    fn agent_packs_defaults_to_empty_map() {
1359        let raw = concat!(
1360            "schema_version: 1\n",
1361            "bundle_id: demo\n",
1362            "bundle_name: Demo Bundle\n",
1363        );
1364        let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1365            .expect("config without agent_packs should parse");
1366        assert!(definition.agent_packs.is_empty());
1367    }
1368
1369    #[test]
1370    fn bundled_catalog_mode_skips_https_provider_materialization() {
1371        unsafe {
1372            std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1373        }
1374        assert!(should_skip_extension_provider_materialization(
1375            "https://example.com/providers/events-webhook.gtpack"
1376        ));
1377        unsafe {
1378            std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1379        }
1380    }
1381
1382    #[test]
1383    fn deployer_provider_destination_uses_canonical_filename() {
1384        assert_eq!(
1385            provider_destination_path(
1386                "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
1387            ),
1388            PathBuf::from("providers/deployer/aws.gtpack")
1389        );
1390    }
1391
1392    /// Round-trip guard: parse a config containing `agent_packs`, render it with
1393    /// `render_bundle_workspace`, re-parse, and assert the map survives intact.
1394    ///
1395    /// This is the "Also close the Task 3 deferral" test required by the SP2 plan.
1396    #[test]
1397    fn agent_packs_round_trips_through_render_bundle_workspace() {
1398        use super::render_bundle_workspace;
1399
1400        let raw = concat!(
1401            "schema_version: 1\n",
1402            "bundle_id: demo\n",
1403            "bundle_name: Demo Bundle\n",
1404            "agent_packs:\n",
1405            "  crm_assistant: \"store://greentic.crm-assistant@1.2.0\"\n",
1406            "  tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1407        );
1408        let original =
1409            serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1410
1411        // Render → re-parse.
1412        let rendered = render_bundle_workspace(&original);
1413        let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1414            .expect("re-parse after render");
1415
1416        // Both entries must survive.
1417        assert_eq!(
1418            round_tripped
1419                .agent_packs
1420                .get("tavily_researcher")
1421                .map(String::as_str),
1422            Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1423            "tavily_researcher must survive the render round-trip"
1424        );
1425        assert_eq!(
1426            round_tripped
1427                .agent_packs
1428                .get("crm_assistant")
1429                .map(String::as_str),
1430            Some("store://greentic.crm-assistant@1.2.0"),
1431            "crm_assistant must survive the render round-trip"
1432        );
1433        assert_eq!(
1434            round_tripped.agent_packs.len(),
1435            2,
1436            "no extra entries should appear after round-trip"
1437        );
1438    }
1439
1440    /// An empty `agent_packs` map must render and re-parse as empty (not missing).
1441    #[test]
1442    fn empty_agent_packs_round_trips_as_empty_map() {
1443        use super::render_bundle_workspace;
1444
1445        let raw = concat!(
1446            "schema_version: 1\n",
1447            "bundle_id: demo\n",
1448            "bundle_name: Demo Bundle\n",
1449        );
1450        let original =
1451            serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1452        assert!(original.agent_packs.is_empty());
1453
1454        let rendered = render_bundle_workspace(&original);
1455        let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1456            .expect("re-parse after render");
1457
1458        assert!(
1459            round_tripped.agent_packs.is_empty(),
1460            "empty agent_packs must survive the render round-trip"
1461        );
1462    }
1463}