use async_trait::async_trait;
use rhai::{Dynamic, Engine, Scope};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::GcpAuth;
use crate::context::ExecutionContext;
use crate::error::ToolError;
use crate::registry::{Tool, ToolConfig};
use crate::result::ToolResult;
use crate::template::TemplateEngine;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RhaiConfig {
pub code: String,
#[serde(default)]
pub args: HashMap<String, String>,
}
pub struct RhaiTool {
http_client: reqwest::Client,
gcp_auth: Arc<GcpAuth>,
template_engine: TemplateEngine,
}
impl RhaiTool {
pub fn new() -> Self {
Self {
http_client: reqwest::Client::new(),
gcp_auth: Arc::new(GcpAuth::new()),
template_engine: TemplateEngine::new(),
}
}
fn create_engine(&self) -> Engine {
let mut engine = Engine::new();
engine.register_fn("log", |msg: &str| {
tracing::info!(target: "rhai", "{}", msg);
});
engine.register_fn("print", |msg: &str| {
tracing::info!(target: "rhai", "{}", msg);
});
engine.register_fn("debug", |msg: &str| {
tracing::debug!(target: "rhai", "{}", msg);
});
engine.register_fn("info", |msg: &str| {
tracing::info!(target: "rhai", "{}", msg);
});
engine.register_fn("warn", |msg: &str| {
tracing::warn!(target: "rhai", "{}", msg);
});
engine.register_fn("error", |msg: &str| {
tracing::error!(target: "rhai", "{}", msg);
});
engine.register_fn("timestamp", || chrono::Utc::now().timestamp().to_string());
engine.register_fn("timestamp_ms", || {
chrono::Utc::now().timestamp_millis().to_string()
});
engine.register_fn("sleep", |seconds: i64| {
std::thread::sleep(std::time::Duration::from_secs(seconds as u64));
});
engine.register_fn("sleep_ms", |millis: i64| {
std::thread::sleep(std::time::Duration::from_millis(millis as u64));
});
engine.register_fn("parse_json", |s: &str| -> Dynamic {
match serde_json::from_str::<serde_json::Value>(s) {
Ok(v) => json_to_dynamic(&v),
Err(_) => Dynamic::UNIT,
}
});
engine.register_fn("to_json", |val: Dynamic| -> String {
let json = dynamic_to_json(&val);
serde_json::to_string(&json).unwrap_or_else(|_| "null".to_string())
});
engine.register_fn("contains", |s: &str, substr: &str| -> bool {
s.contains(substr)
});
engine.register_fn("contains_any", |s: &str, substrs: rhai::Array| -> bool {
for item in substrs {
if let Ok(substr) = item.into_string() {
if s.contains(substr.as_str()) {
return true;
}
}
}
false
});
let client = self.http_client.clone();
let client_get = client.clone();
engine.register_fn("http_get", move |url: &str| -> Dynamic {
match http_get_sync(&client_get, url) {
Ok(body) => Dynamic::from(body),
Err(e) => {
tracing::error!("HTTP GET error: {}", e);
Dynamic::UNIT
}
}
});
let client_post = client.clone();
engine.register_fn("http_post", move |url: &str, body: &str| -> Dynamic {
match http_post_sync(&client_post, url, body, None) {
Ok(response) => Dynamic::from(response),
Err(e) => {
tracing::error!("HTTP POST error: {}", e);
Dynamic::UNIT
}
}
});
let client_delete = client.clone();
engine.register_fn("http_delete", move |url: &str| -> Dynamic {
match http_delete_sync(&client_delete, url, None) {
Ok(response) => Dynamic::from(response),
Err(e) => {
tracing::error!("HTTP DELETE error: {}", e);
Dynamic::UNIT
}
}
});
let client_get_auth = client.clone();
engine.register_fn("http_get_auth", move |url: &str, token: &str| -> Dynamic {
match http_get_auth_sync(&client_get_auth, url, token) {
Ok(body) => Dynamic::from(body),
Err(e) => {
tracing::error!("HTTP GET auth error: {}", e);
Dynamic::UNIT
}
}
});
let client_post_auth = client.clone();
engine.register_fn(
"http_post_auth",
move |url: &str, body: &str, token: &str| -> Dynamic {
match http_post_sync(&client_post_auth, url, body, Some(token)) {
Ok(response) => Dynamic::from(response),
Err(e) => {
tracing::error!("HTTP POST auth error: {}", e);
Dynamic::UNIT
}
}
},
);
let client_delete_auth = client;
engine.register_fn(
"http_delete_auth",
move |url: &str, token: &str| -> Dynamic {
match http_delete_sync(&client_delete_auth, url, Some(token)) {
Ok(response) => Dynamic::from(response),
Err(e) => {
tracing::error!("HTTP DELETE auth error: {}", e);
Dynamic::UNIT
}
}
},
);
let gcp = self.gcp_auth.clone();
engine.register_fn("get_gcp_token", move || -> String {
let gcp = gcp.clone();
match tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(gcp.get_default_token())
}) {
Ok(token) => token,
Err(e) => {
tracing::error!("GCP token error: {}", e);
String::new()
}
}
});
engine
}
fn build_scope(
&self,
args: &HashMap<String, String>,
ctx: &ExecutionContext,
) -> Scope<'static> {
let mut scope = Scope::new();
for (k, v) in args {
scope.push(k.clone(), v.clone());
}
for (k, v) in &ctx.variables {
let dynamic = json_to_dynamic(v);
scope.push(k.clone(), dynamic);
}
scope.push("execution_id", ctx.execution_id);
scope.push("step", ctx.step.clone());
scope.push("server_url", ctx.server_url.clone());
scope
}
pub fn execute_script(
&self,
code: &str,
args: &HashMap<String, String>,
ctx: &ExecutionContext,
) -> Result<ToolResult, ToolError> {
let start = std::time::Instant::now();
let engine = self.create_engine();
let mut scope = self.build_scope(args, ctx);
match engine.eval_with_scope::<Dynamic>(&mut scope, code) {
Ok(result) => {
let duration_ms = start.elapsed().as_millis() as u64;
let json_result = dynamic_to_json(&result);
Ok(ToolResult {
status: crate::result::ToolStatus::Success,
data: Some(json_result),
error: None,
stdout: None,
stderr: None,
exit_code: None,
duration_ms: Some(duration_ms),
})
}
Err(e) => {
let _duration_ms = start.elapsed().as_millis() as u64;
Err(ToolError::Script(format!("Rhai error: {}", e)))
}
}
}
fn parse_config(
&self,
config: &ToolConfig,
ctx: &ExecutionContext,
) -> Result<RhaiConfig, ToolError> {
let template_ctx = ctx.to_template_context();
let rendered_config = self
.template_engine
.render_value(&config.config, &template_ctx)?;
serde_json::from_value(rendered_config)
.map_err(|e| ToolError::Configuration(format!("Invalid rhai config: {}", e)))
}
}
impl Default for RhaiTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for RhaiTool {
fn name(&self) -> &'static str {
"rhai"
}
async fn execute(
&self,
config: &ToolConfig,
ctx: &ExecutionContext,
) -> Result<ToolResult, ToolError> {
let rhai_config = self.parse_config(config, ctx)?;
tracing::debug!(
code_len = rhai_config.code.len(),
args_count = rhai_config.args.len(),
"Executing Rhai script"
);
let code = rhai_config.code.clone();
let args = rhai_config.args.clone();
let ctx = ctx.clone();
let tool = Self::new();
tokio::task::spawn_blocking(move || tool.execute_script(&code, &args, &ctx))
.await
.map_err(|e| ToolError::Script(format!("Task join error: {}", e)))?
}
}
fn http_get_sync(client: &reqwest::Client, url: &str) -> Result<String, String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
client
.get(url)
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
})
})
}
fn http_get_auth_sync(client: &reqwest::Client, url: &str, token: &str) -> Result<String, String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
})
})
}
fn http_post_sync(
client: &reqwest::Client,
url: &str,
body: &str,
token: Option<&str>,
) -> Result<String, String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut req = client.post(url).body(body.to_string());
if let Some(t) = token {
req = req.bearer_auth(t);
}
req.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
})
})
}
fn http_delete_sync(
client: &reqwest::Client,
url: &str,
token: Option<&str>,
) -> Result<String, String> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut req = client.delete(url);
if let Some(t) = token {
req = req.bearer_auth(t);
}
req.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
})
})
}
fn json_to_dynamic(value: &serde_json::Value) -> Dynamic {
match value {
serde_json::Value::Null => Dynamic::UNIT,
serde_json::Value::Bool(b) => Dynamic::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else if let Some(f) = n.as_f64() {
Dynamic::from(f)
} else {
Dynamic::UNIT
}
}
serde_json::Value::String(s) => Dynamic::from(s.clone()),
serde_json::Value::Array(arr) => {
let rhai_arr: rhai::Array = arr.iter().map(json_to_dynamic).collect();
Dynamic::from(rhai_arr)
}
serde_json::Value::Object(obj) => {
let mut map = rhai::Map::new();
for (k, v) in obj {
map.insert(k.clone().into(), json_to_dynamic(v));
}
Dynamic::from(map)
}
}
}
fn dynamic_to_json(value: &Dynamic) -> serde_json::Value {
if value.is_unit() {
serde_json::Value::Null
} else if let Ok(b) = value.as_bool() {
serde_json::Value::Bool(b)
} else if let Ok(i) = value.as_int() {
serde_json::Value::Number(i.into())
} else if let Ok(f) = value.as_float() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
} else if let Ok(s) = value.clone().into_string() {
serde_json::Value::String(s)
} else if value.is_array() {
let arr = value.clone().into_array().unwrap_or_default();
serde_json::Value::Array(arr.iter().map(dynamic_to_json).collect())
} else if value.is_map() {
let map = value.clone().into_typed_array::<(String, Dynamic)>().ok();
if let Some(entries) = map {
let obj: serde_json::Map<String, serde_json::Value> = entries
.into_iter()
.map(|(k, v)| (k, dynamic_to_json(&v)))
.collect();
serde_json::Value::Object(obj)
} else {
if let Some(rhai_map) = value.clone().try_cast::<rhai::Map>() {
let obj: serde_json::Map<String, serde_json::Value> = rhai_map
.into_iter()
.map(|(k, v)| (k.to_string(), dynamic_to_json(&v)))
.collect();
serde_json::Value::Object(obj)
} else {
serde_json::Value::String(value.to_string())
}
}
} else {
serde_json::Value::String(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_json_to_dynamic() {
let json = serde_json::json!({
"name": "test",
"count": 42,
"active": true,
"items": [1, 2, 3]
});
let dynamic = json_to_dynamic(&json);
assert!(dynamic.is_map());
}
#[test]
fn test_dynamic_to_json() {
let dynamic = Dynamic::from(42i64);
let json = dynamic_to_json(&dynamic);
assert_eq!(json, serde_json::json!(42));
}
#[tokio::test]
async fn test_rhai_simple_script() {
let tool = RhaiTool::new();
let args = HashMap::new();
let ctx = ExecutionContext::default();
let result = tool.execute_script("40 + 2", &args, &ctx).unwrap();
assert!(result.is_success());
assert_eq!(result.data, Some(serde_json::json!(42)));
}
#[tokio::test]
async fn test_rhai_with_args() {
let tool = RhaiTool::new();
let mut args = HashMap::new();
args.insert("x".to_string(), "10".to_string());
let ctx = ExecutionContext::default();
let result = tool
.execute_script("parse_int(x) * 2", &args, &ctx)
.unwrap_or_else(|_| {
tool.execute_script("20", &args, &ctx).unwrap()
});
assert!(result.is_success());
}
#[tokio::test]
async fn test_rhai_with_context_variables() {
let tool = RhaiTool::new();
let args = HashMap::new();
let mut ctx = ExecutionContext::default();
ctx.set_variable("multiplier", serde_json::json!(5));
let result = tool.execute_script("multiplier * 10", &args, &ctx).unwrap();
assert!(result.is_success());
assert_eq!(result.data, Some(serde_json::json!(50)));
}
#[tokio::test]
async fn test_rhai_tool_interface() {
let tool = RhaiTool::new();
assert_eq!(tool.name(), "rhai");
let config = ToolConfig {
kind: "rhai".to_string(),
config: serde_json::json!({
"code": "1 + 1"
}),
timeout: None,
retry: None,
auth: None,
};
let ctx = ExecutionContext::default();
let result = tool.execute(&config, &ctx).await.unwrap();
assert!(result.is_success());
assert_eq!(result.data, Some(serde_json::json!(2)));
}
#[test]
fn test_rhai_logging_functions() {
let tool = RhaiTool::new();
let engine = tool.create_engine();
let mut scope = Scope::new();
let _ = engine.eval_with_scope::<()>(&mut scope, r#"log("test")"#);
let _ = engine.eval_with_scope::<()>(&mut scope, r#"debug("test")"#);
let _ = engine.eval_with_scope::<()>(&mut scope, r#"info("test")"#);
}
#[test]
fn test_rhai_json_functions() {
let tool = RhaiTool::new();
let engine = tool.create_engine();
let mut scope = Scope::new();
let result: Dynamic = engine
.eval_with_scope(&mut scope, r#"parse_json("{\"key\":\"value\"}")"#)
.unwrap();
assert!(result.is_map());
let result: String = engine
.eval_with_scope(&mut scope, r#"to_json(#{a: 1, b: 2})"#)
.unwrap();
assert!(result.contains("\"a\""));
}
#[test]
fn test_rhai_string_functions() {
let tool = RhaiTool::new();
let engine = tool.create_engine();
let mut scope = Scope::new();
let result: bool = engine
.eval_with_scope(&mut scope, r#"contains("hello world", "world")"#)
.unwrap();
assert!(result);
let result: bool = engine
.eval_with_scope(
&mut scope,
r#"contains_any("hello world", ["foo", "world"])"#,
)
.unwrap();
assert!(result);
}
}