greentic-flow-builder 0.2.0

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! Bundle generation: .gtpack → wizard apply → greentic-bundle build → .gtbundle

use super::OrchestrateError;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct BundleResult {
    pub bundle_path: PathBuf,
    pub workspace_path: PathBuf,
    pub bundle_id: String,
}

/// Generate a .gtbundle from a .gtpack file using the full wizard pipeline.
///
/// 1. Create temp workspace
/// 2. Generate answers.json for wizard apply
/// 3. Run `greentic-bundle wizard apply --answers answers.json`
///    (this resolves OCI refs, materializes providers, creates lock/manifest)
/// 4. Run `greentic-bundle build --root .`
/// 5. Return .gtbundle path
pub fn build_bundle(
    pack_path: &Path,
    bundle_name: &str,
    providers: Option<&[String]>,
) -> Result<BundleResult, OrchestrateError> {
    if !pack_path.exists() {
        return Err(OrchestrateError::Cards2Pack(format!(
            "pack file not found: {}",
            pack_path.display()
        )));
    }

    let tmp = tempfile::tempdir()?;
    let workspace = tmp.path().to_path_buf();
    let bundle_id = sanitize(bundle_name);

    let pack_filename = pack_path
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_else(|| format!("{bundle_id}.gtpack"));

    // Copy .gtpack to workspace
    let local_pack = workspace.join(&pack_filename);
    std::fs::copy(pack_path, &local_pack)?;

    // Provider refs
    let provider_refs: Vec<String> = match providers {
        Some(provs) if !provs.is_empty() => provs.to_vec(),
        _ => vec!["oci://ghcr.io/greenticai/packs/state/state-memory:latest".to_string()],
    };

    // Build provider entries for answers.json
    let provider_entries: Vec<String> = provider_refs
        .iter()
        .map(|r| {
            let id = r
                .rsplit('/')
                .next()
                .unwrap_or("provider")
                .split(':')
                .next()
                .unwrap_or("provider");
            format!(
                r#"      {{
        "reference": "{r}",
        "detected_kind": "oci",
        "provider_id": "{id}",
        "display_name": "{id}"
      }}"#
            )
        })
        .collect();

    // Generate answers.json for wizard apply
    let pack_ref = format!("file://./{pack_filename}");
    let answers_json = format!(
        r#"{{
  "wizard_id": "greentic-bundle.wizard.run",
  "schema_id": "greentic-bundle.wizard.answers",
  "schema_version": "1.0.0",
  "locale": "en",
  "answers": {{
    "bundle_id": "{bundle_id}",
    "bundle_name": "{bundle_name}",
    "advanced_setup": false,
    "app_packs": ["{pack_ref}"],
    "app_pack_entries": [
      {{
        "reference": "{pack_ref}",
        "detected_kind": "file",
        "display_name": "{bundle_name}",
        "pack_id": "{bundle_id}",
        "mapping": {{ "scope": "global" }}
      }}
    ],
    "extension_providers": [{provider_refs_json}],
    "extension_provider_entries": [
{provider_entries_joined}
    ],
    "access_rules": [
      {{
        "rule_path": "{bundle_id}",
        "policy": "allow",
        "tenant": "default"
      }}
    ],
    "setup_execution_intent": false,
    "export_intent": false
  }}
}}"#,
        bundle_id = bundle_id,
        bundle_name = bundle_name,
        pack_ref = pack_ref,
        provider_refs_json = provider_refs
            .iter()
            .map(|r| format!("\"{r}\""))
            .collect::<Vec<_>>()
            .join(", "),
        provider_entries_joined = provider_entries.join(",\n"),
    );

    let answers_path = workspace.join("answers.json");
    std::fs::write(&answers_path, &answers_json)?;

    // Step 1: Run wizard apply (resolves OCI, materializes providers, creates lock)
    // Wizard creates workspace in {bundle_id}-bundle/ subdirectory
    let wizard_output = std::process::Command::new("greentic-bundle")
        .args([
            "wizard",
            "apply",
            "--answers",
            &answers_path.display().to_string(),
        ])
        .current_dir(&workspace)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()?;

    // Wizard creates workspace at ./{bundle_id}-bundle/
    let bundle_workspace = workspace.join(format!("{bundle_id}-bundle"));

    if !wizard_output.status.success() {
        let stderr = String::from_utf8_lossy(&wizard_output.stderr);
        let stdout = String::from_utf8_lossy(&wizard_output.stdout);
        if !bundle_workspace.join("bundle.yaml").exists() {
            return Err(OrchestrateError::Cards2Pack(format!(
                "greentic-bundle wizard apply failed (exit {}):\n{}\n{}",
                wizard_output.status.code().unwrap_or(-1),
                stderr.trim(),
                stdout.trim()
            )));
        }
    }

    // Step 2: Run build from the wizard-created workspace
    let build_output = std::process::Command::new("greentic-bundle")
        .args(["build", "--root", &bundle_workspace.display().to_string()])
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()?;

    if !build_output.status.success() {
        let stderr = String::from_utf8_lossy(&build_output.stderr);
        let stdout = String::from_utf8_lossy(&build_output.stdout);
        return Err(OrchestrateError::Cards2Pack(format!(
            "greentic-bundle build failed (exit {}):\n{}\n{}",
            build_output.status.code().unwrap_or(-1),
            stderr.trim(),
            stdout.trim()
        )));
    }

    // Find .gtbundle — search in wizard workspace first, then parent
    if let Some(bundle_path) = find_gtbundle(&bundle_workspace) {
        let _ = tmp.keep();
        return Ok(BundleResult {
            bundle_path,
            workspace_path: bundle_workspace,
            bundle_id,
        });
    }

    if let Some(bundle_path) = find_gtbundle(&workspace) {
        let _ = tmp.keep();
        return Ok(BundleResult {
            bundle_path,
            workspace_path: bundle_workspace,
            bundle_id,
        });
    }

    Err(OrchestrateError::Cards2Pack(
        "greentic-bundle build succeeded but no .gtbundle file found".to_string(),
    ))
}

fn find_gtbundle(dir: &Path) -> Option<PathBuf> {
    if !dir.exists() {
        return None;
    }
    // Search recursively up to 3 levels deep
    find_file_recursive(dir, "gtbundle", 3)
}

fn find_file_recursive(dir: &Path, ext: &str, depth: u8) -> Option<PathBuf> {
    if depth == 0 {
        return None;
    }
    for entry in std::fs::read_dir(dir).ok()?.flatten() {
        let path = entry.path();
        if path.extension().is_some_and(|e| e == ext) {
            return Some(path);
        }
        if let Some(found) = path
            .is_dir()
            .then(|| find_file_recursive(&path, ext, depth - 1))
            .flatten()
        {
            return Some(found);
        }
    }
    None
}

fn sanitize(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect()
}