bundle-standard-core 1.2.0-dev.25540595305

Pure-Rust workspace+ZIP assembly for the bundle-standard recipe.
Documentation
//! Synthesize the bundle workspace tree as in-memory entries.

use crate::errors::PackError;
use crate::types::{PackInputs, StandardConfig};
use greentic_types::{PackId, PackKind, PackManifest, PackSignatures, encode_pack_manifest};
use semver::Version;

pub fn synthesize_workspace(inputs: &PackInputs<'_>) -> Result<Vec<(String, Vec<u8>)>, PackError> {
    if inputs.config.format != "gtpack-legacy" {
        return Err(PackError::InvalidFormat(inputs.config.format.clone()));
    }

    let mut entries: Vec<(String, Vec<u8>)> = Vec::new();

    // manifest.cbor — minimum-viable PackManifest so greentic-start's
    // bundle inspector (which requires this entry on every pack —
    // see `greentic-start::startup_contract::pack_declares_static_routes`)
    // can decode the descriptor and proceed. Card-only packs don't
    // ship WASM components or a structured Flow graph; we keep
    // `components` / `flows` empty and let the runtime read the
    // YGTc + AdaptiveCard JSON straight from the workspace tree.
    entries.push(("manifest.cbor".into(), build_manifest_cbor(inputs.config)?));

    // bundle.yaml
    entries.push((
        "bundle.yaml".into(),
        bundle_yaml(inputs.config).into_bytes(),
    ));

    // flows/<name>.ygtc
    for flow in inputs.flows {
        entries.push((
            format!("flows/{}.ygtc", flow.name),
            flow.yaml.as_bytes().to_vec(),
        ));
    }

    // assets/cards/<id>.json
    for card in inputs.cards {
        let pretty = serde_json::to_vec_pretty(&card.json)?;
        entries.push((format!("assets/cards/{}.json", card.id), pretty));
    }

    // assets/<rel_path> from raw assets
    for (rel, bytes) in inputs.assets {
        entries.push((format!("assets/{rel}"), bytes.clone()));
    }

    // tenants/default/tenant.gmap
    entries.push((
        "tenants/default/tenant.gmap".into(),
        tenant_gmap(inputs.capabilities).into_bytes(),
    ));

    // Sort for deterministic ZIP ordering.
    entries.sort_by(|a, b| a.0.cmp(&b.0));

    Ok(entries)
}

/// Build a minimum-viable `manifest.cbor` payload for a card-only
/// pack. Required fields use real values from the recipe config;
/// everything else (components, flows, dependencies, capabilities,
/// secret_requirements, signatures) ships empty so the canonical
/// CBOR encoder is happy.
fn build_manifest_cbor(config: &StandardConfig) -> Result<Vec<u8>, PackError> {
    let pack_id: PackId =
        config
            .metadata
            .name
            .parse()
            .map_err(|e: greentic_types::GreenticError| {
                PackError::InvalidConfig(format!("metadata.name as PackId: {e}"))
            })?;
    let version = Version::parse(&config.metadata.version)
        .map_err(|e| PackError::InvalidConfig(format!("metadata.version as semver: {e}")))?;
    let publisher = config
        .metadata
        .author
        .as_deref()
        .unwrap_or("Greentic")
        .to_string();
    let manifest = PackManifest {
        schema_version: "pack-v1".into(),
        pack_id,
        name: Some(config.metadata.name.clone()),
        version,
        kind: PackKind::Application,
        publisher,
        components: Vec::new(),
        flows: Vec::new(),
        dependencies: Vec::new(),
        capabilities: Vec::new(),
        secret_requirements: Vec::new(),
        signatures: PackSignatures::default(),
        bootstrap: None,
        extensions: None,
    };
    encode_pack_manifest(&manifest)
        .map_err(|e| PackError::ManifestCbor(format!("encode_pack_manifest: {e}")))
}

fn bundle_yaml(config: &StandardConfig) -> String {
    let channels: String = config
        .channels
        .iter()
        .map(|c| format!("  - {c}\n"))
        .collect();
    format!(
        "apiVersion: greentic.ai/v1\nkind: BundleWorkspace\nmetadata:\n  name: {}\n  version: {}\nchannels:\n{}",
        config.metadata.name, config.metadata.version, channels,
    )
}

fn tenant_gmap(caps: &[String]) -> String {
    let caps: String = caps.iter().map(|c| format!("  - {c}\n")).collect();
    format!("# generated by bundle-standard-core\ntenant: default\ncapabilities:\n{caps}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{CardContentEntry, FlowEntry, I18nConfig, StandardConfig, StandardMetadata};
    use serde_json::json;

    fn cfg() -> StandardConfig {
        StandardConfig {
            metadata: StandardMetadata {
                name: "demo".into(),
                version: "0.1.0".into(),
                author: None,
            },
            channels: vec!["webchat".into()],
            embed_ui: "webchat".into(),
            i18n: I18nConfig::default(),
            format: "gtpack-legacy".into(),
        }
    }

    #[test]
    fn entries_sorted() {
        let cfg = cfg();
        let flows = vec![FlowEntry {
            name: "main".into(),
            yaml: "x".into(),
        }];
        let cards = vec![CardContentEntry {
            id: "welcome".into(),
            json: json!({}),
        }];
        let inputs = PackInputs {
            config: &cfg,
            flows: &flows,
            cards: &cards,
            assets: &[],
            capabilities: &[],
        };
        let entries = synthesize_workspace(&inputs).unwrap();
        let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[test]
    fn rejects_non_legacy_format() {
        let mut config = cfg();
        config.format = "apack".into();
        let inputs = PackInputs {
            config: &config,
            flows: &[],
            cards: &[],
            assets: &[],
            capabilities: &[],
        };
        let err = synthesize_workspace(&inputs).unwrap_err();
        assert_eq!(err.code(), "E_INVALID_FORMAT");
    }
}