use serde_json::Value;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct HttpNodeEntry {
pub id: String,
pub url: String,
pub method: String,
pub body_mapping: Vec<(String, String)>,
pub prev_card_id: Option<String>,
pub next_entry_id: Option<String>,
}
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();
let prev_card_id = card_entries
.last()
.map(|(id, _): &(String, Value)| id.clone());
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)
}
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 {
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);
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);
}
}
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"))
};
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,
);
if let Some(end_pos) = content.find("# END GENERATED") {
content.insert_str(end_pos, &http_block);
} else {
content.push_str(&http_block);
}
}
content
}
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"));
}
}