greentic-flow-builder 0.3.1

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! Cards-to-pack orchestration.

use super::OrchestrateError;
use super::http_inject::HttpNodeEntry;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;

/// Prepared workspace ready for greentic-cards2pack subprocess.
pub struct PreparedCards {
    tmp: tempfile::TempDir,
    pub cards_dir: PathBuf,
    pub out_dir: PathBuf,
    pub flow_name: String,
    /// HTTP node entries extracted from the flow (injected post-cards2pack).
    pub http_entries: Vec<HttpNodeEntry>,
}

impl PreparedCards {
    pub fn persist(self) {
        let _ = self.tmp.keep();
    }
}

/// Write cards to a temp directory with proper greentic metadata.
///
/// The first card (index 0) is treated as the entrypoint. If its ID is not
/// "welcome", it gets renamed to "welcome" so cards2pack picks it as the
/// flow start node. All routing references are updated accordingly.
pub fn prepare_cards(
    name: &str,
    cards: &[(String, Value)],
) -> Result<PreparedCards, OrchestrateError> {
    if cards.is_empty() {
        return Err(OrchestrateError::Cards2Pack(
            "no cards provided".to_string(),
        ));
    }

    let tmp = tempfile::tempdir()?;
    let cards_dir = tmp.path().join("cards");
    std::fs::create_dir_all(&cards_dir)?;

    let flow_name = sanitize_filename(name);

    // Separate HTTP entries from card entries
    let (card_only, http_entries) = super::http_inject::extract_http_entries(cards);

    // Build ID rename map: first card → "welcome" (if not already)
    let mut id_map: HashMap<String, String> = HashMap::new();
    let first_id = &card_only[0].0;
    let has_welcome = card_only.iter().any(|(id, _)| id == "welcome");

    if first_id != "welcome" && !has_welcome {
        // Rename first card to "welcome" for entrypoint
        id_map.insert(first_id.clone(), "welcome".to_string());
    }

    for (original_id, card) in &card_only {
        let mut card = card.clone();
        let card_id = id_map
            .get(original_id)
            .cloned()
            .unwrap_or_else(|| sanitize_filename(original_id));

        // Inject greentic metadata
        if let Some(obj) = card.as_object_mut() {
            let greentic = obj
                .entry("greentic")
                .or_insert_with(|| serde_json::json!({}));
            if let Some(g) = greentic.as_object_mut() {
                g.insert("cardId".to_string(), Value::String(card_id.clone()));
                g.insert("flow".to_string(), Value::String(flow_name.clone()));
            }
        }

        // Rewrite nextCardId → routeToCardId + action_id, applying ID remaps
        rewrite_next_card_ids(&mut card, &id_map);

        let filename = format!("{card_id}.json");
        let path = cards_dir.join(&filename);
        let json = serde_json::to_string_pretty(&card).map_err(|e| {
            OrchestrateError::Cards2Pack(format!("serialize card {original_id}: {e}"))
        })?;
        std::fs::write(&path, json)?;
    }

    let out_dir = tmp.path().join("workspace");
    std::fs::create_dir_all(&out_dir)?;

    Ok(PreparedCards {
        tmp,
        cards_dir,
        out_dir,
        flow_name,
        http_entries,
    })
}

/// Recursively rewrite `data.nextCardId` → `data.routeToCardId` + `data.action_id`.
/// Applies ID remaps (e.g. "main_menu" → "welcome") to routing targets.
fn rewrite_next_card_ids(value: &mut Value, id_map: &HashMap<String, String>) {
    match value {
        Value::Object(obj) => {
            let next = obj
                .get("data")
                .and_then(|d| d.get("nextCardId"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());

            if let Some(next) = next {
                // Apply ID remap if this target was renamed
                let target = id_map
                    .get(&next)
                    .cloned()
                    .unwrap_or_else(|| sanitize_filename(&next));
                let action_id = format!("goto_{target}");

                if let Some(d) = obj.get_mut("data").and_then(|d| d.as_object_mut()) {
                    d.remove("nextCardId");
                    d.insert("routeToCardId".to_string(), Value::String(target));
                    d.insert("action_id".to_string(), Value::String(action_id));
                }
            }

            for v in obj.values_mut() {
                rewrite_next_card_ids(v, id_map);
            }
        }
        Value::Array(arr) => {
            for v in arr.iter_mut() {
                rewrite_next_card_ids(v, id_map);
            }
        }
        _ => {}
    }
}

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