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
588fn provider_destination_path(reference: &str) -> PathBuf {
589    let provider_type = inferred_provider_type(reference);
590    let provider_name = inferred_provider_filename(reference);
591    PathBuf::from("providers")
592        .join(provider_type)
593        .join(format!("{provider_name}.gtpack"))
594}
595
596fn materialize_reference_into(
597    root: &Path,
598    reference_roots: &[PathBuf],
599    reference: &str,
600    relative_destination: &Path,
601) -> Result<()> {
602    let destination = root.join(relative_destination);
603    if destination.exists() {
604        if !crate::runtime::refresh() {
605            return Ok(());
606        }
607        std::fs::remove_file(&destination)
608            .with_context(|| format!("remove existing {} before refresh", destination.display()))?;
609    }
610    if let Some(parent) = destination.parent() {
611        ensure_dir(parent)?;
612    }
613
614    if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
615        if local_path.is_dir() {
616            return Ok(());
617        }
618        std::fs::copy(&local_path, &destination).with_context(|| {
619            format!("copy {} to {}", local_path.display(), destination.display())
620        })?;
621        return Ok(());
622    }
623
624    if !(reference.starts_with("oci://")
625        || reference.starts_with("repo://")
626        || reference.starts_with("store://")
627        || reference.starts_with("https://"))
628    {
629        return Ok(());
630    }
631
632    let path = resolve_remote_pack_path(root, reference)?;
633    std::fs::copy(&path, &destination)
634        .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
635
636    Ok(())
637}
638
639fn parse_local_pack_reference(
640    root: &Path,
641    reference_roots: &[PathBuf],
642    reference: &str,
643) -> Option<PathBuf> {
644    if let Some(path) = reference.strip_prefix("file://") {
645        let path = PathBuf::from(path.trim());
646        if path.is_absolute() {
647            return path.exists().then_some(path);
648        }
649        for base in reference_roots
650            .iter()
651            .map(PathBuf::as_path)
652            .chain(std::iter::once(root))
653        {
654            let candidate = base.join(&path);
655            if candidate.exists() {
656                return Some(candidate);
657            }
658        }
659        return None;
660    }
661    if reference.contains("://") {
662        return None;
663    }
664    let candidate = PathBuf::from(reference);
665    if candidate.is_absolute() {
666        return candidate.exists().then_some(candidate);
667    }
668    for base in reference_roots
669        .iter()
670        .map(PathBuf::as_path)
671        .chain(std::iter::once(root))
672    {
673        let joined = base.join(&candidate);
674        if joined.exists() {
675            return Some(joined);
676        }
677    }
678    None
679}
680
681fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
682    if let Some(oci_reference) = reference.strip_prefix("oci://") {
683        let mut options = PackFetchOptions {
684            allow_tags: true,
685            offline: crate::runtime::offline(),
686            cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
687            ..PackFetchOptions::default()
688        };
689        options.accepted_layer_media_types.extend([
690            GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
691            GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
692        ]);
693        options.preferred_layer_media_types.splice(
694            0..0,
695            [
696                GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
697                GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
698            ],
699        );
700        let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
701        let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
702        let resolved = runtime
703            .block_on(fetcher.fetch_pack_to_cache(oci_reference))
704            .with_context(|| format!("resolve OCI pack ref {reference}"))?;
705        return Ok(resolved.path);
706    }
707
708    let options = DistOptions {
709        allow_tags: true,
710        offline: crate::runtime::offline(),
711        cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
712        ..DistOptions::default()
713    };
714    let client = DistClient::new(options);
715    let runtime = Runtime::new().context("create artifact resolver runtime")?;
716    let source = client
717        .parse_source(reference)
718        .with_context(|| format!("parse artifact ref {reference}"))?;
719    let descriptor = runtime
720        .block_on(client.resolve(source, ResolvePolicy))
721        .with_context(|| format!("resolve artifact ref {reference}"))?;
722    let resolved = runtime
723        .block_on(client.fetch(&descriptor, CachePolicy))
724        .with_context(|| format!("fetch artifact ref {reference}"))?;
725    if let Some(path) = resolved.wasm_path {
726        return Ok(path);
727    }
728    if let Some(bytes) = resolved.wasm_bytes {
729        let digest = resolved.resolved_digest.trim_start_matches("sha256:");
730        let temp_path = root
731            .join(crate::catalog::CACHE_ROOT_DIR)
732            .join("artifacts")
733            .join("inline")
734            .join(format!("{digest}.gtpack"));
735        if let Some(parent) = temp_path.parent() {
736            ensure_dir(parent)?;
737        }
738        std::fs::write(&temp_path, bytes)
739            .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
740        return Ok(temp_path);
741    }
742    anyhow::bail!("artifact ref {reference} resolved without file payload");
743}
744
745pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
746    let tenants_dir = root.join("tenants");
747    let mut tenants = Vec::new();
748    if !tenants_dir.exists() {
749        return Ok(tenants);
750    }
751    for entry in std::fs::read_dir(tenants_dir)? {
752        let entry = entry?;
753        if entry.file_type()?.is_dir() {
754            tenants.push(entry.file_name().to_string_lossy().to_string());
755        }
756    }
757    tenants.sort();
758    Ok(tenants)
759}
760
761pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
762    let teams_dir = root.join("tenants").join(tenant).join("teams");
763    let mut teams = Vec::new();
764    if !teams_dir.exists() {
765        return Ok(teams);
766    }
767    for entry in std::fs::read_dir(teams_dir)? {
768        let entry = entry?;
769        if entry.file_type()?.is_dir() {
770            teams.push(entry.file_name().to_string_lossy().to_string());
771        }
772    }
773    teams.sort();
774    Ok(teams)
775}
776
777/// Locate the bundle lock under `root`, accepting either on-disk layout.
778///
779/// A bundle *workspace* names the lock [`LOCK_FILE`]; the normalized *artifact*
780/// layout extracted from a `.gtbundle` names it [`ARTIFACT_LOCK_FILE`]. Both
781/// carry the same [`BundleLock`], so readers accept whichever is present and
782/// prefer the workspace name when a directory somehow carries both.
783///
784/// Returns `None` when neither name exists.
785pub fn resolve_lock_path(root: &Path) -> Option<PathBuf> {
786    let workspace = root.join(LOCK_FILE);
787    if workspace.is_file() {
788        return Some(workspace);
789    }
790    let artifact = root.join(ARTIFACT_LOCK_FILE);
791    if artifact.is_file() {
792        return Some(artifact);
793    }
794    None
795}
796
797pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
798    // Update the lock already on disk under whichever name it uses, so an
799    // artifact-layout directory is not left with two lock files that drift.
800    let path = resolve_lock_path(root).unwrap_or_else(|| root.join(LOCK_FILE));
801    if let Some(parent) = path.parent() {
802        ensure_dir(parent)?;
803    }
804    std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
805    Ok(())
806}
807
808pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
809    let path = resolve_lock_path(root).ok_or_else(|| {
810        anyhow::anyhow!(
811            "no bundle lock in {}: expected `{LOCK_FILE}` (workspace layout) or \
812             `{ARTIFACT_LOCK_FILE}` (artifact layout)",
813            root.display()
814        )
815    })?;
816    let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
817    serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
818}
819
820fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
821    let workspace = read_workspace_or_default(root);
822    let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
823    let team_gmap = team.map(|team| {
824        relative_path(
825            root,
826            &root
827                .join("tenants")
828                .join(tenant)
829                .join("teams")
830                .join(team)
831                .join("team.gmap"),
832        )
833    });
834
835    let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
836
837    ResolvedManifest {
838        version: "1".to_string(),
839        tenant: tenant.to_string(),
840        team: team.map(ToOwned::to_owned),
841        project_root: root.display().to_string(),
842        bundle: BundleSummary {
843            bundle_id: workspace.bundle_id,
844            bundle_name: workspace.bundle_name,
845            locale: workspace.locale,
846            mode: workspace.mode,
847            advanced_setup: workspace.advanced_setup,
848            setup_execution_intent: workspace.setup_execution_intent,
849            export_intent: workspace.export_intent,
850        },
851        policy: PolicySection {
852            source: PolicySource {
853                tenant_gmap,
854                team_gmap,
855            },
856            default: "forbidden".to_string(),
857        },
858        catalogs: workspace.remote_catalogs,
859        app_packs,
860        extension_providers: workspace.extension_providers,
861        hooks: workspace.hooks,
862        subscriptions: workspace.subscriptions,
863        capabilities: workspace.capabilities,
864    }
865}
866
867fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
868    // NOTE: keep this format! in lockstep with `BundleWorkspaceDefinition`; every
869    // field must be emitted so a parse→render→re-parse round-trip is lossless.
870    format!(
871        concat!(
872            "schema_version: {}\n",
873            "bundle_id: {}\n",
874            "bundle_name: {}\n",
875            "locale: {}\n",
876            "mode: {}\n",
877            "advanced_setup: {}\n",
878            "agent_packs:{}\n",
879            "app_packs:{}\n",
880            "app_pack_mappings:{}\n",
881            "extension_providers:{}\n",
882            "remote_catalogs:{}\n",
883            "hooks:{}\n",
884            "subscriptions:{}\n",
885            "capabilities:{}\n",
886            "setup_execution_intent: {}\n",
887            "export_intent: {}\n"
888        ),
889        workspace.schema_version,
890        workspace.bundle_id,
891        workspace.bundle_name,
892        workspace.locale,
893        workspace.mode,
894        workspace.advanced_setup,
895        yaml_sorted_string_map(&workspace.agent_packs),
896        yaml_list(&workspace.app_packs),
897        yaml_mapping_list(&workspace.app_pack_mappings),
898        yaml_list(&workspace.extension_providers),
899        yaml_list(&workspace.remote_catalogs),
900        yaml_list(&workspace.hooks),
901        yaml_list(&workspace.subscriptions),
902        yaml_list(&workspace.capabilities),
903        workspace.setup_execution_intent,
904        workspace.export_intent
905    )
906}
907
908fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
909    if values.is_empty() {
910        " []".to_string()
911    } else {
912        values
913            .iter()
914            .map(|value| {
915                let mut out = format!(
916                    "\n  - reference: {}\n    scope: {}",
917                    value.reference,
918                    match value.scope {
919                        MappingScope::Global => "global",
920                        MappingScope::Tenant => "tenant",
921                        MappingScope::Team => "team",
922                    }
923                );
924                if let Some(tenant) = &value.tenant {
925                    out.push_str(&format!("\n    tenant: {tenant}"));
926                }
927                if let Some(team) = &value.team {
928                    out.push_str(&format!("\n    team: {team}"));
929                }
930                out
931            })
932            .collect::<String>()
933    }
934}
935
936fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
937    BundleLock {
938        schema_version: LOCK_SCHEMA_VERSION,
939        bundle_id: workspace.bundle_id.clone(),
940        env_id: None,
941        requested_mode: workspace.mode.clone(),
942        execution: "execute".to_string(),
943        cache_policy: "workspace-local".to_string(),
944        tool_version: env!("CARGO_PKG_VERSION").to_string(),
945        build_format_version: "bundle-lock-v1".to_string(),
946        workspace_root: WORKSPACE_ROOT_FILE.to_string(),
947        lock_file: LOCK_FILE.to_string(),
948        catalogs: Vec::new(),
949        app_packs: workspace
950            .app_packs
951            .iter()
952            .map(|reference| DependencyLock {
953                reference: reference.clone(),
954                digest: None,
955            })
956            .collect(),
957        extension_providers: workspace
958            .extension_providers
959            .iter()
960            .map(|reference| DependencyLock {
961                reference: reference.clone(),
962                digest: None,
963            })
964            .collect(),
965        setup_state_files: Vec::new(),
966    }
967}
968
969fn yaml_list(values: &[String]) -> String {
970    if values.is_empty() {
971        " []".to_string()
972    } else {
973        values
974            .iter()
975            .map(|value| format!("\n  - {value}"))
976            .collect::<String>()
977    }
978}
979
980/// Serialize a `BTreeMap<String, String>` as a YAML block mapping.
981///
982/// An empty map emits ` {}`.  Non-empty entries are sorted by key (BTreeMap
983/// guarantees this already) and emitted as `\n  <key>: "<value>"`.  String
984/// values are always quoted to handle values that contain YAML-special characters
985/// (colons, slashes, etc.).
986fn yaml_sorted_string_map(map: &BTreeMap<String, String>) -> String {
987    if map.is_empty() {
988        return " {}".to_string();
989    }
990    map.iter()
991        .map(|(key, value)| format!("\n  {key}: \"{value}\""))
992        .collect()
993}
994
995fn sort_unique(values: &mut Vec<String>) {
996    values.retain(|value| !value.trim().is_empty());
997    values.sort();
998    values.dedup();
999}
1000
1001fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
1002    values.retain(|value| !value.reference.trim().is_empty());
1003    for value in values.iter_mut() {
1004        if value
1005            .tenant
1006            .as_deref()
1007            .is_some_and(|tenant| tenant.trim().is_empty())
1008        {
1009            value.tenant = None;
1010        }
1011        if value
1012            .team
1013            .as_deref()
1014            .is_some_and(|team| team.trim().is_empty())
1015        {
1016            value.team = None;
1017        }
1018        if matches!(value.scope, MappingScope::Global) {
1019            value.tenant = None;
1020            value.team = None;
1021        } else if matches!(value.scope, MappingScope::Tenant) {
1022            value.team = None;
1023        }
1024    }
1025    values.sort_by(|left, right| {
1026        left.reference
1027            .cmp(&right.reference)
1028            .then(left.scope.cmp(&right.scope))
1029            .then(left.tenant.cmp(&right.tenant))
1030            .then(left.team.cmp(&right.team))
1031    });
1032    values.dedup_by(|left, right| {
1033        left.reference == right.reference
1034            && left.scope == right.scope
1035            && left.tenant == right.tenant
1036            && left.team == right.team
1037    });
1038}
1039
1040fn default_schema_version() -> u32 {
1041    1
1042}
1043
1044fn default_locale() -> String {
1045    "en".to_string()
1046}
1047
1048fn default_mode() -> String {
1049    "create".to_string()
1050}
1051
1052fn write_resolved_outputs(
1053    root: &Path,
1054    tenant: &str,
1055    team: Option<&str>,
1056    manifest: &ResolvedManifest,
1057) -> Result<()> {
1058    let yaml = render_manifest_yaml(manifest);
1059    for output in resolved_output_paths(root, tenant, team) {
1060        if let Some(parent) = output.parent() {
1061            ensure_dir(parent)?;
1062        }
1063        std::fs::write(output, &yaml)?;
1064    }
1065    Ok(())
1066}
1067
1068fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
1069    let mut lines = vec![
1070        format!("version: {}", manifest.version),
1071        format!("tenant: {}", manifest.tenant),
1072    ];
1073    if let Some(team) = &manifest.team {
1074        lines.push(format!("team: {}", team));
1075    }
1076    lines.extend([
1077        format!("project_root: {}", manifest.project_root),
1078        "bundle:".to_string(),
1079        format!("  bundle_id: {}", manifest.bundle.bundle_id),
1080        format!("  bundle_name: {}", manifest.bundle.bundle_name),
1081        format!("  locale: {}", manifest.bundle.locale),
1082        format!("  mode: {}", manifest.bundle.mode),
1083        format!("  advanced_setup: {}", manifest.bundle.advanced_setup),
1084        format!(
1085            "  setup_execution_intent: {}",
1086            manifest.bundle.setup_execution_intent
1087        ),
1088        format!("  export_intent: {}", manifest.bundle.export_intent),
1089        "policy:".to_string(),
1090        "  source:".to_string(),
1091        format!("    tenant_gmap: {}", manifest.policy.source.tenant_gmap),
1092    ]);
1093    if let Some(team_gmap) = &manifest.policy.source.team_gmap {
1094        lines.push(format!("    team_gmap: {}", team_gmap));
1095    }
1096    lines.push(format!("  default: {}", manifest.policy.default));
1097    lines.push("catalogs:".to_string());
1098    lines.extend(render_yaml_list("  ", &manifest.catalogs));
1099    lines.push("app_packs:".to_string());
1100    if manifest.app_packs.is_empty() {
1101        lines.push("  []".to_string());
1102    } else {
1103        for entry in &manifest.app_packs {
1104            lines.push(format!("  - reference: {}", entry.reference));
1105            lines.push(format!("    policy: {}", entry.policy));
1106        }
1107    }
1108    lines.push("extension_providers:".to_string());
1109    lines.extend(render_yaml_list("  ", &manifest.extension_providers));
1110    lines.push("hooks:".to_string());
1111    lines.extend(render_yaml_list("  ", &manifest.hooks));
1112    lines.push("subscriptions:".to_string());
1113    lines.extend(render_yaml_list("  ", &manifest.subscriptions));
1114    lines.push("capabilities:".to_string());
1115    lines.extend(render_yaml_list("  ", &manifest.capabilities));
1116    format!("{}\n", lines.join("\n"))
1117}
1118
1119fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
1120    read_bundle_workspace(root).unwrap_or_else(|_| {
1121        let bundle_id = root
1122            .file_name()
1123            .and_then(|value| value.to_str())
1124            .map(ToOwned::to_owned)
1125            .filter(|value| !value.trim().is_empty())
1126            .unwrap_or_else(|| "bundle".to_string());
1127        BundleWorkspaceDefinition::new(
1128            bundle_id.clone(),
1129            bundle_id,
1130            default_locale(),
1131            default_mode(),
1132        )
1133    })
1134}
1135
1136fn evaluate_app_pack_policies(
1137    root: &Path,
1138    tenant: &str,
1139    team: Option<&str>,
1140    app_packs: &[String],
1141) -> Vec<ResolvedReferencePolicy> {
1142    let tenant_rules =
1143        crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
1144            .unwrap_or_default();
1145    let team_rules = team
1146        .and_then(|team_name| {
1147            crate::access::parse_file(
1148                &root
1149                    .join("tenants")
1150                    .join(tenant)
1151                    .join("teams")
1152                    .join(team_name)
1153                    .join("team.gmap"),
1154            )
1155            .ok()
1156        })
1157        .unwrap_or_default();
1158
1159    let mut entries = app_packs
1160        .iter()
1161        .map(|reference| {
1162            let target = crate::access::GmapPath {
1163                pack: Some(inferred_access_pack_id(reference)),
1164                flow: None,
1165                node: None,
1166            };
1167            let policy = if team.is_some() {
1168                crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
1169            } else {
1170                crate::access::eval_policy(&tenant_rules, &target)
1171            };
1172            ResolvedReferencePolicy {
1173                reference: reference.clone(),
1174                policy: policy
1175                    .map(|decision| decision.policy.to_string())
1176                    .unwrap_or_else(|| "unset".to_string()),
1177            }
1178        })
1179        .collect::<Vec<_>>();
1180    entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1181    entries
1182}
1183
1184fn inferred_access_pack_id(reference: &str) -> String {
1185    let cleaned = reference
1186        .trim_end_matches('/')
1187        .rsplit('/')
1188        .next()
1189        .unwrap_or(reference)
1190        .split('@')
1191        .next()
1192        .unwrap_or(reference)
1193        .split(':')
1194        .next()
1195        .unwrap_or(reference)
1196        .trim_end_matches(".json")
1197        .trim_end_matches(".gtpack")
1198        .trim_end_matches(".yaml")
1199        .trim_end_matches(".yml");
1200    let mut normalized = String::with_capacity(cleaned.len());
1201    let mut last_dash = false;
1202    for ch in cleaned.chars() {
1203        let out = if ch.is_ascii_alphanumeric() {
1204            last_dash = false;
1205            ch.to_ascii_lowercase()
1206        } else if last_dash {
1207            continue;
1208        } else {
1209            last_dash = true;
1210            '-'
1211        };
1212        normalized.push(out);
1213    }
1214    normalized.trim_matches('-').to_string()
1215}
1216
1217fn inferred_provider_type(reference: &str) -> String {
1218    let raw = reference.trim();
1219    for marker in ["/providers/", "/packs/"] {
1220        if let Some((_, rest)) = raw.split_once(marker)
1221            && let Some(segment) = rest.split('/').next()
1222            && !segment.is_empty()
1223        {
1224            return segment.to_string();
1225        }
1226    }
1227
1228    let inferred = inferred_access_pack_id(reference);
1229    let mut parts = inferred.split('-');
1230    match (parts.next(), parts.next()) {
1231        (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1232        (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1233        (Some(_domain), None) => "other".to_string(),
1234        _ => "other".to_string(),
1235    }
1236}
1237
1238fn inferred_provider_filename(reference: &str) -> String {
1239    let cleaned = reference
1240        .trim_end_matches('/')
1241        .rsplit('/')
1242        .next()
1243        .unwrap_or(reference)
1244        .split('@')
1245        .next()
1246        .unwrap_or(reference)
1247        .split(':')
1248        .next()
1249        .unwrap_or(reference)
1250        .trim_end_matches(".gtpack");
1251    if let Some(deployer_target) = cleaned.strip_prefix("greentic.deploy.")
1252        && !deployer_target.trim().is_empty()
1253    {
1254        return deployer_target.trim().to_string();
1255    }
1256    if cleaned.is_empty() {
1257        inferred_access_pack_id(reference)
1258    } else {
1259        cleaned.to_string()
1260    }
1261}
1262
1263fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1264    if values.is_empty() {
1265        vec![format!("{indent}[]")]
1266    } else {
1267        values
1268            .iter()
1269            .map(|value| format!("{indent}- {value}"))
1270            .collect()
1271    }
1272}
1273
1274fn relative_path(root: &Path, path: &Path) -> String {
1275    path.strip_prefix(root)
1276        .unwrap_or(path)
1277        .display()
1278        .to_string()
1279}
1280
1281fn ensure_dir(path: &Path) -> Result<()> {
1282    std::fs::create_dir_all(path)?;
1283    Ok(())
1284}
1285
1286fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1287    if path.exists() {
1288        return Ok(());
1289    }
1290    if let Some(parent) = path.parent() {
1291        ensure_dir(parent)?;
1292    }
1293    std::fs::write(path, contents)?;
1294    Ok(())
1295}
1296
1297/// Extracts `assets/webchat-gui/` entries from all provider `.gtpack` files into
1298/// the bundle root so users can see and directly modify skins, config, and other
1299/// webchat-gui assets. Other internal pack assets (fixtures, schemas,
1300/// secret-requirements, etc.) are intentionally excluded. Existing files are
1301/// never overwritten — user customizations are preserved.
1302pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1303    let mut written = Vec::new();
1304    let providers_dir = root.join("providers");
1305    if !providers_dir.is_dir() {
1306        return Ok(written);
1307    }
1308    for dir_entry in collect_gtpack_files(&providers_dir)? {
1309        match extract_pack_assets(root, &dir_entry) {
1310            Ok(paths) => written.extend(paths),
1311            Err(err) => {
1312                eprintln!(
1313                    "Warning: could not scaffold assets from {}: {err}",
1314                    dir_entry.display()
1315                );
1316            }
1317        }
1318    }
1319    Ok(written)
1320}
1321
1322fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1323    let mut files = Vec::new();
1324    for entry in std::fs::read_dir(dir)? {
1325        let entry = entry?;
1326        let path = entry.path();
1327        if path.is_dir() {
1328            files.extend(collect_gtpack_files(&path)?);
1329        } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1330            files.push(path);
1331        }
1332    }
1333    Ok(files)
1334}
1335
1336fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1337    let file =
1338        std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1339    let mut archive =
1340        zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1341    let mut written = Vec::new();
1342    for i in 0..archive.len() {
1343        let mut entry = archive.by_index(i)?;
1344        let name = entry.name().to_string();
1345        if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1346            continue;
1347        }
1348        let target = root.join(&name);
1349        if target.exists() {
1350            continue;
1351        }
1352        if let Some(parent) = target.parent() {
1353            std::fs::create_dir_all(parent)?;
1354        }
1355        let mut out = std::fs::File::create(&target)?;
1356        std::io::copy(&mut entry, &mut out)?;
1357        written.push(target);
1358    }
1359    Ok(written)
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use std::path::PathBuf;
1365
1366    use super::BundleWorkspaceDefinition;
1367    use super::{provider_destination_path, should_skip_extension_provider_materialization};
1368
1369    #[test]
1370    fn agent_packs_parses_into_map() {
1371        let raw = concat!(
1372            "schema_version: 1\n",
1373            "bundle_id: demo\n",
1374            "bundle_name: Demo Bundle\n",
1375            "agent_packs:\n",
1376            "  tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1377        );
1378        let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1379            .expect("config with agent_packs should parse");
1380        assert_eq!(
1381            definition
1382                .agent_packs
1383                .get("tavily_researcher")
1384                .map(String::as_str),
1385            Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1386        );
1387    }
1388
1389    #[test]
1390    fn agent_packs_defaults_to_empty_map() {
1391        let raw = concat!(
1392            "schema_version: 1\n",
1393            "bundle_id: demo\n",
1394            "bundle_name: Demo Bundle\n",
1395        );
1396        let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1397            .expect("config without agent_packs should parse");
1398        assert!(definition.agent_packs.is_empty());
1399    }
1400
1401    #[test]
1402    fn bundled_catalog_mode_skips_https_provider_materialization() {
1403        unsafe {
1404            std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1405        }
1406        assert!(should_skip_extension_provider_materialization(
1407            "https://example.com/providers/events-webhook.gtpack"
1408        ));
1409        unsafe {
1410            std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1411        }
1412    }
1413
1414    #[test]
1415    fn deployer_provider_destination_uses_canonical_filename() {
1416        assert_eq!(
1417            provider_destination_path(
1418                "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
1419            ),
1420            PathBuf::from("providers/deployer/aws.gtpack")
1421        );
1422    }
1423
1424    /// Round-trip guard: parse a config containing `agent_packs`, render it with
1425    /// `render_bundle_workspace`, re-parse, and assert the map survives intact.
1426    ///
1427    /// This is the "Also close the Task 3 deferral" test required by the SP2 plan.
1428    #[test]
1429    fn agent_packs_round_trips_through_render_bundle_workspace() {
1430        use super::render_bundle_workspace;
1431
1432        let raw = concat!(
1433            "schema_version: 1\n",
1434            "bundle_id: demo\n",
1435            "bundle_name: Demo Bundle\n",
1436            "agent_packs:\n",
1437            "  crm_assistant: \"store://greentic.crm-assistant@1.2.0\"\n",
1438            "  tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1439        );
1440        let original =
1441            serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1442
1443        // Render → re-parse.
1444        let rendered = render_bundle_workspace(&original);
1445        let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1446            .expect("re-parse after render");
1447
1448        // Both entries must survive.
1449        assert_eq!(
1450            round_tripped
1451                .agent_packs
1452                .get("tavily_researcher")
1453                .map(String::as_str),
1454            Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1455            "tavily_researcher must survive the render round-trip"
1456        );
1457        assert_eq!(
1458            round_tripped
1459                .agent_packs
1460                .get("crm_assistant")
1461                .map(String::as_str),
1462            Some("store://greentic.crm-assistant@1.2.0"),
1463            "crm_assistant must survive the render round-trip"
1464        );
1465        assert_eq!(
1466            round_tripped.agent_packs.len(),
1467            2,
1468            "no extra entries should appear after round-trip"
1469        );
1470    }
1471
1472    /// An empty `agent_packs` map must render and re-parse as empty (not missing).
1473    #[test]
1474    fn empty_agent_packs_round_trips_as_empty_map() {
1475        use super::render_bundle_workspace;
1476
1477        let raw = concat!(
1478            "schema_version: 1\n",
1479            "bundle_id: demo\n",
1480            "bundle_name: Demo Bundle\n",
1481        );
1482        let original =
1483            serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1484        assert!(original.agent_packs.is_empty());
1485
1486        let rendered = render_bundle_workspace(&original);
1487        let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1488            .expect("re-parse after render");
1489
1490        assert!(
1491            round_tripped.agent_packs.is_empty(),
1492            "empty agent_packs must survive the render round-trip"
1493        );
1494    }
1495
1496    fn sample_lock() -> super::BundleLock {
1497        let raw = concat!(
1498            "schema_version: 1\n",
1499            "bundle_id: demo\n",
1500            "bundle_name: Demo Bundle\n",
1501        );
1502        let workspace =
1503            serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse workspace");
1504        super::empty_bundle_lock(&workspace)
1505    }
1506
1507    fn write_lock_as(root: &std::path::Path, name: &str, lock: &super::BundleLock) {
1508        std::fs::write(
1509            root.join(name),
1510            serde_json::to_string_pretty(lock).expect("serialize lock"),
1511        )
1512        .expect("write lock");
1513    }
1514
1515    #[test]
1516    fn read_bundle_lock_reads_workspace_layout() {
1517        let dir = tempfile::tempdir().expect("tempdir");
1518        write_lock_as(dir.path(), super::LOCK_FILE, &sample_lock());
1519
1520        let lock = super::read_bundle_lock(dir.path()).expect("read workspace lock");
1521        assert_eq!(lock.bundle_id, "demo");
1522    }
1523
1524    /// Regression: an extracted `.gtbundle` names its lock `bundle-lock.json`.
1525    /// Reading it used to fail with a bare ENOENT for `bundle.lock.json`.
1526    #[test]
1527    fn read_bundle_lock_reads_artifact_layout() {
1528        let dir = tempfile::tempdir().expect("tempdir");
1529        write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1530
1531        let lock = super::read_bundle_lock(dir.path()).expect("read artifact lock");
1532        assert_eq!(lock.bundle_id, "demo");
1533    }
1534
1535    #[test]
1536    fn workspace_lock_wins_when_both_names_are_present() {
1537        let dir = tempfile::tempdir().expect("tempdir");
1538        let mut workspace_lock = sample_lock();
1539        workspace_lock.bundle_id = "workspace".to_string();
1540        let mut artifact_lock = sample_lock();
1541        artifact_lock.bundle_id = "artifact".to_string();
1542        write_lock_as(dir.path(), super::LOCK_FILE, &workspace_lock);
1543        write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &artifact_lock);
1544
1545        let lock = super::read_bundle_lock(dir.path()).expect("read lock");
1546        assert_eq!(lock.bundle_id, "workspace");
1547    }
1548
1549    #[test]
1550    fn read_bundle_lock_names_both_layouts_when_missing() {
1551        let dir = tempfile::tempdir().expect("tempdir");
1552
1553        let err = super::read_bundle_lock(dir.path()).expect_err("must fail with no lock");
1554        let message = err.to_string();
1555        assert!(message.contains(super::LOCK_FILE), "message: {message}");
1556        assert!(
1557            message.contains(super::ARTIFACT_LOCK_FILE),
1558            "message: {message}"
1559        );
1560    }
1561
1562    /// Writing back to an artifact-layout directory must update the file that is
1563    /// already there, not leave two lock files free to drift apart.
1564    #[test]
1565    fn write_bundle_lock_updates_artifact_name_in_place() {
1566        let dir = tempfile::tempdir().expect("tempdir");
1567        write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1568
1569        let mut updated = sample_lock();
1570        updated.bundle_id = "updated".to_string();
1571        super::write_bundle_lock(dir.path(), &updated).expect("write lock");
1572
1573        assert!(
1574            !dir.path().join(super::LOCK_FILE).exists(),
1575            "must not create a second lock file"
1576        );
1577        let reread = super::read_bundle_lock(dir.path()).expect("reread lock");
1578        assert_eq!(reread.bundle_id, "updated");
1579    }
1580
1581    #[test]
1582    fn write_bundle_lock_defaults_to_workspace_name_on_empty_root() {
1583        let dir = tempfile::tempdir().expect("tempdir");
1584
1585        super::write_bundle_lock(dir.path(), &sample_lock()).expect("write lock");
1586
1587        assert!(dir.path().join(super::LOCK_FILE).exists());
1588        assert!(!dir.path().join(super::ARTIFACT_LOCK_FILE).exists());
1589    }
1590}