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