greentic-flow-builder 0.2.0

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! Post-processor: inject component-http nodes into .ygtc flow files.
//!
//! After cards2pack generates a card-only .ygtc, this module:
//! 1. Reads the generated YAML
//! 2. Inserts component.exec blocks for HTTP nodes
//! 3. Rewrites routing: prev_card → http_node → next_card
//! 4. Adds component-http to pack.yaml component_sources

use serde_json::Value;
use std::path::Path;

/// An HTTP node extracted from the LLM flow output.
#[derive(Debug, Clone)]
pub struct HttpNodeEntry {
    pub id: String,
    pub url: String,
    pub method: String,
    pub body_mapping: Vec<(String, String)>,
    /// ID of the card that routes TO this HTTP node (via nextCardId).
    pub prev_card_id: Option<String>,
    /// ID of the next entry after this HTTP node in the flow array.
    pub next_entry_id: Option<String>,
}

/// Extract HTTP node entries from the LLM flow cards array.
///
/// Returns (card_entries, http_entries) where card_entries are the normal
/// card nodes to pass to cards2pack, and http_entries are the HTTP nodes
/// to inject after cards2pack finishes.
pub fn extract_http_entries(
    cards: &[(String, Value)],
) -> (Vec<(String, Value)>, Vec<HttpNodeEntry>) {
    let mut card_entries = Vec::new();
    let mut http_entries = Vec::new();

    for (i, (id, value)) in cards.iter().enumerate() {
        let is_http = value
            .get("type")
            .and_then(Value::as_str)
            .is_some_and(|t| t == "http");

        if is_http {
            let config = value.get("config").cloned().unwrap_or_default();
            let url = config
                .get("url")
                .and_then(Value::as_str)
                .unwrap_or("/api/unknown")
                .to_string();
            let method = config
                .get("method")
                .and_then(Value::as_str)
                .unwrap_or("POST")
                .to_string();

            let body_mapping: Vec<(String, String)> = config
                .get("body_mapping")
                .and_then(Value::as_object)
                .map(|obj| {
                    obj.iter()
                        .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
                        .collect()
                })
                .unwrap_or_default();

            // prev = last card entry before this HTTP node
            let prev_card_id = card_entries
                .last()
                .map(|(id, _): &(String, Value)| id.clone());

            // next = next entry in original array
            let next_entry_id = cards.get(i + 1).map(|(id, _)| id.clone());

            http_entries.push(HttpNodeEntry {
                id: id.clone(),
                url,
                method,
                body_mapping,
                prev_card_id,
                next_entry_id,
            });
        } else {
            card_entries.push((id.clone(), value.clone()));
        }
    }

    (card_entries, http_entries)
}

/// Inject HTTP nodes into the generated .ygtc flow file.
///
/// For each HTTP node:
/// 1. Append a component.exec YAML block
/// 2. Rewrite prev card's routing to point to HTTP node
/// 3. Set HTTP node's routing to point to next card
pub fn inject_http_nodes(ygtc_content: &str, http_entries: &[HttpNodeEntry]) -> String {
    if http_entries.is_empty() {
        return ygtc_content.to_string();
    }

    let mut content = ygtc_content.to_string();

    for entry in http_entries {
        // 1. Rewrite prev card's routing to point to HTTP node
        if let (Some(prev_id), Some(next_id)) = (&entry.prev_card_id, &entry.next_entry_id) {
            let old_route = format!("- to: {next_id}");
            let new_route = format!("- to: {}", entry.id);
            // Only replace the first occurrence after prev_id's node block
            if let Some(prev_pos) = content.find(&format!("  {}:", prev_id))
                && let Some(route_pos) = content[prev_pos..].find(&old_route)
            {
                let abs_pos = prev_pos + route_pos;
                content.replace_range(abs_pos..abs_pos + old_route.len(), &new_route);
            }
        }

        // 2. Build body mapping with template references
        let body_yaml = if entry.body_mapping.is_empty() {
            String::new()
        } else {
            let prev = entry.prev_card_id.as_deref().unwrap_or("unknown");
            let fields: Vec<String> = entry
                .body_mapping
                .iter()
                .map(|(key, val)| {
                    let field_ref = val.trim_start_matches("${").trim_end_matches('}');
                    format!("          {key}: \"{{{{node.{prev}.result.{field_ref}}}}}\"")
                })
                .collect();
            format!("\n        body:\n{}", fields.join("\n"))
        };

        // 3. Build the component.exec YAML block
        let next_routing = entry
            .next_entry_id
            .as_deref()
            .map(|id| format!("\n    routing:\n      - to: {id}"))
            .unwrap_or_else(|| "\n    routing: out".to_string());

        let http_block = format!(
            r#"
  # ─── HTTP: {id} ───
  {id}:
    component.exec:
      component: ai.greentic.component-http
      operation: request
      input:
        url: "{url}"
        method: {method}{body_yaml}{next_routing}
"#,
            id = entry.id,
            url = entry.url,
            method = entry.method,
        );

        // 4. Inject before the END GENERATED marker (or at end of nodes section)
        if let Some(end_pos) = content.find("# END GENERATED") {
            content.insert_str(end_pos, &http_block);
        } else {
            content.push_str(&http_block);
        }
    }

    content
}

/// Ensure component-http is listed in pack.yaml component_sources.
pub fn ensure_component_http_source(pack_yaml_path: &Path) -> std::io::Result<()> {
    let content = std::fs::read_to_string(pack_yaml_path)?;

    if content.contains("component-http") {
        return Ok(());
    }

    let mut updated = content;
    if updated.contains("component_sources:") {
        if let Some(pos) = updated.find("component_sources:") {
            let insert_pos = updated[pos..]
                .find('\n')
                .map(|p| pos + p + 1)
                .unwrap_or(updated.len());
            updated.insert_str(
                insert_pos,
                "  - component_id: ai.greentic.component-http\n    source: oci://ghcr.io/greenticai/components/component-http:latest\n",
            );
        }
    } else {
        updated.push_str(
            "\ncomponent_sources:\n  - component_id: ai.greentic.component-http\n    source: oci://ghcr.io/greenticai/components/component-http:latest\n",
        );
    }

    std::fs::write(pack_yaml_path, updated)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn extract_separates_card_and_http_entries() {
        let entries: Vec<(String, Value)> = vec![
            ("welcome".into(), json!({"card": {"type": "AdaptiveCard"}})),
            ("form".into(), json!({"card": {"type": "AdaptiveCard"}})),
            (
                "api_create".into(),
                json!({
                    "type": "http",
                    "config": {
                        "url": "/api/tickets",
                        "method": "POST",
                        "body_mapping": {"cat": "${category}"}
                    }
                }),
            ),
            ("confirm".into(), json!({"card": {"type": "AdaptiveCard"}})),
        ];

        let (cards, https) = extract_http_entries(&entries);
        assert_eq!(cards.len(), 3);
        assert_eq!(https.len(), 1);
        assert_eq!(https[0].id, "api_create");
        assert_eq!(https[0].url, "/api/tickets");
        assert_eq!(https[0].prev_card_id.as_deref(), Some("form"));
        assert_eq!(https[0].next_entry_id.as_deref(), Some("confirm"));
    }

    #[test]
    fn inject_adds_component_exec_block() {
        let ygtc = "# BEGIN GENERATED (cards2pack)\nnodes:\n  form:\n    routing:\n      - to: confirm\n  confirm:\n    routing: out\n# END GENERATED\n";
        let http = vec![HttpNodeEntry {
            id: "api_create".into(),
            url: "/api/tickets".into(),
            method: "POST".into(),
            body_mapping: vec![("cat".into(), "${category}".into())],
            prev_card_id: Some("form".into()),
            next_entry_id: Some("confirm".into()),
        }];

        let result = inject_http_nodes(ygtc, &http);
        assert!(result.contains("api_create:"));
        assert!(result.contains("component: ai.greentic.component-http"));
        assert!(result.contains("url: \"/api/tickets\""));
        assert!(result.contains("{{node.form.result.category}}"));
        assert!(result.contains("- to: api_create"));
        assert!(result.contains("- to: confirm"));
    }
}