greentic-pack-dev 1.1.26495471727

Greentic pack builder CLI
Documentation
use crate::flows::FlowAsset;
use crate::templates::TemplateAsset;
use anyhow::{Context, Result, anyhow};
use pack_component_template::{CARGO_TOML, DATA_RS_PLACEHOLDER, LIB_RS};
use std::fmt::Write;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::info;

pub fn generate_component_data(
    manifest_bytes: &[u8],
    flows: &[FlowAsset],
    templates: &[TemplateAsset],
) -> Result<String> {
    let mut buffer = String::new();
    writeln!(
        &mut buffer,
        "// @generated by packc -- DO NOT EDIT BY HAND."
    )?;
    writeln!(&mut buffer, "#![allow(dead_code)]")?;
    writeln!(&mut buffer, "#![allow(clippy::all)]\n")?;

    writeln!(&mut buffer, "pub static MANIFEST_CBOR: &[u8] = &[")?;
    buffer.push_str(&indent_byte_literal(manifest_bytes, 4));
    writeln!(&mut buffer, "\n];\n")?;

    writeln!(
        &mut buffer,
        "pub static FLOWS: &[(&'static str, &'static str)] = &["
    )?;
    for flow in flows {
        let raw_literal = rust_string_literal(&flow.raw);
        writeln!(
            &mut buffer,
            "    (\"{}\", {}),",
            flow.bundle.id, raw_literal
        )?;
    }
    writeln!(&mut buffer, "];\n")?;

    writeln!(
        &mut buffer,
        "pub static TEMPLATES: &[(&'static str, &'static [u8])] = &["
    )?;
    for template in templates {
        writeln!(&mut buffer, "    (\"{}\", &[", template.logical_path)?;
        buffer.push_str(&indent_byte_literal(&template.bytes, 8));
        writeln!(&mut buffer, "\n    ]),")?;
    }
    writeln!(&mut buffer, "];\n")?;

    Ok(buffer)
}

pub fn compile_component(component_data: &Path, output_wasm: &Path) -> Result<()> {
    let crate_root = prepare_component_crate(component_data)?;

    info!(
        component_data = %component_data.display(),
        crate_root = %crate_root.display(),
        output = %output_wasm.display(),
        "compiling pack_component"
    );

    ensure_target_installed("wasm32-wasip2")?;

    let metadata_status = Command::new("cargo")
        .args(["metadata", "--format-version", "1"])
        .current_dir(&crate_root)
        .status()
        .with_context(|| "failed to invoke `cargo metadata` for pack_component")?;

    if !metadata_status.success() {
        anyhow::bail!(
            "unable to run cargo metadata for pack_component; reinstall packc or ensure the Rust toolchain is installed (crate path: {})",
            crate_root.display()
        );
    }

    let build_status = Command::new("cargo")
        .args(["build", "--target", "wasm32-wasip2", "--release"])
        .current_dir(&crate_root)
        .status()
        .with_context(|| "failed to invoke cargo build for pack_component")?;

    if !build_status.success() {
        anyhow::bail!("cargo build failed with status {}", build_status);
    }

    let artifact = crate_root
        .join("target")
        .join("wasm32-wasip2")
        .join("release")
        .join("pack_component.wasm");

    if !artifact.exists() {
        anyhow::bail!(
            "expected wasm artifact at {} but it was not produced",
            artifact.display()
        );
    }

    if let Some(parent) = output_wasm.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory {}", parent.display()))?;
    }

    fs::copy(&artifact, output_wasm).with_context(|| {
        format!(
            "failed to copy wasm artifact from {} to {}",
            artifact.display(),
            output_wasm.display()
        )
    })?;

    info!(artifact = %artifact.display(), output = %output_wasm.display(), "component artifact ready");
    Ok(())
}

fn ensure_target_installed(target: &str) -> Result<()> {
    let output = Command::new("rustup")
        .args(["target", "list", "--installed"])
        .output();

    let Ok(output) = output else {
        // If rustup is unavailable we assume the caller will manage targets manually.
        info!("skipping rustup target check; rustup not available");
        return Ok(());
    };

    if !output.status.success() {
        info!("rustup target list --installed failed; assuming target is configured");
        return Ok(());
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    if stdout.lines().any(|line| line.trim() == target) {
        Ok(())
    } else {
        anyhow::bail!(
            "Rust target `{}` is not installed. Install it via `rustup target add {}` before running packc.",
            target,
            target
        );
    }
}

fn indent_byte_literal(bytes: &[u8], indent: usize) -> String {
    if bytes.is_empty() {
        return String::new();
    }

    let mut lines = Vec::new();
    for chunk in bytes.chunks(12) {
        let mut line = String::new();
        for (idx, byte) in chunk.iter().enumerate() {
            if idx > 0 {
                line.push_str(", ");
            }
            write!(&mut line, "0x{:02x}", byte).unwrap();
        }
        lines.push(format!(
            "{indentation}{line}",
            indentation = " ".repeat(indent),
            line = line
        ));
    }

    lines.join(",\n")
}

fn rust_string_literal(value: &str) -> String {
    let mut literal = String::from("\"");
    for ch in value.chars() {
        for escaped in ch.escape_default() {
            literal.push(escaped);
        }
    }
    literal.push('"');
    literal
}

fn prepare_component_crate(component_data: &Path) -> Result<PathBuf> {
    let src_dir = component_data
        .parent()
        .ok_or_else(|| anyhow!("component data path lacks parent"))?;
    fs::create_dir_all(src_dir)
        .with_context(|| format!("failed to ensure src directory {}", src_dir.display()))?;
    let crate_root = src_dir
        .parent()
        .ok_or_else(|| anyhow!("component data path lacks crate root"))?;

    write_template_file(crate_root.join("Cargo.toml"), CARGO_TOML)?;
    write_template_file(src_dir.join("lib.rs"), LIB_RS)?;
    if !component_data.exists() {
        write_template_file(component_data.to_path_buf(), DATA_RS_PLACEHOLDER)?;
    }

    Ok(crate_root.to_path_buf())
}

fn write_template_file(path: PathBuf, contents: &str) -> Result<()> {
    if path.exists() {
        let existing = fs::read_to_string(&path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        if existing == contents {
            return Ok(());
        }
    } else if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory {}", parent.display()))?;
    }

    fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{flows, manifest, templates};

    fn demo_pack_dir() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("..")
            .join("..")
            .join("examples/weather-demo")
    }

    #[test]
    fn component_data_contains_mcp_exec_flow_source() {
        let pack_dir = demo_pack_dir();
        let spec = manifest::load_spec(&pack_dir).expect("spec");
        let flow_assets = flows::load_flows(&pack_dir, &spec.spec).expect("flows");
        let template_assets =
            templates::collect_templates(&pack_dir, &spec.spec).expect("templates");
        let manifest_model = manifest::build_manifest(&spec, &flow_assets, &template_assets);
        let manifest_bytes = manifest::encode_manifest(&manifest_model).expect("manifest encoding");

        let generated =
            generate_component_data(&manifest_bytes, &flow_assets, &template_assets).unwrap();

        assert!(
            generated.contains("pub static MANIFEST_CBOR"),
            "generated source should expose MANIFEST_CBOR constant"
        );
        assert!(
            generated.contains("mcp.exec"),
            "generated flow bundle should retain mcp.exec reference"
        );
        assert!(
            generated.contains("templates/weather_now.hbs"),
            "template logical path should be present"
        );
    }

    #[test]
    fn indent_byte_literal_outputs_comma_separated_rows() {
        let bytes = [0x01u8, 0x02, 0x03, 0x04];
        let rendered = indent_byte_literal(&bytes, 4);
        assert_eq!(
            rendered, "    0x01, 0x02, 0x03, 0x04",
            "literal should match expected formatting"
        );
    }
}