use std::collections::{BTreeMap, HashSet};
use serde::{Deserialize, Deserializer, Serialize, de};
use serde_json::Value;
use tmcp::schema::{CallToolResult, ContentBlock};
use crate::types::{Rect, WidgetRef};
pub(super) type ScriptResult<T> = Result<T, ScriptErrorInfo>;
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum ScriptArgValue {
String(String),
Int(i64),
Float(f64),
Bool(bool),
}
impl<'de> Deserialize<'de> for ScriptArgValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(value) => Ok(Self::String(value)),
Value::Bool(value) => Ok(Self::Bool(value)),
Value::Number(number) => {
if let Some(value) = number.as_i64() {
return Ok(Self::Int(value));
}
if number.is_u64() {
return Err(de::Error::custom("script arg integers must fit in i64"));
}
number
.as_f64()
.map(Self::Float)
.ok_or_else(|| de::Error::custom("script arg number is not representable"))
}
Value::Null => Err(de::Error::custom("script args do not accept null values")),
Value::Array(_) => Err(de::Error::custom("script args do not accept arrays")),
Value::Object(_) => Err(de::Error::custom("script args do not accept objects")),
}
}
}
pub type ScriptArgs = BTreeMap<String, ScriptArgValue>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct ScriptEvalOptions {
pub source_name: Option<String>,
#[serde(default)]
pub args: ScriptArgs,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScriptEvalRequest {
pub script: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<ScriptEvalOptions>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct ScriptPosition {
pub(super) line: Option<usize>,
pub(super) column: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScriptLocation {
pub line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScriptAssertion {
pub passed: bool,
pub message: String,
pub location: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScriptTiming {
pub compile_ms: u64,
pub exec_ms: u64,
pub total_ms: u64,
}
impl ScriptTiming {
pub(crate) fn zero() -> Self {
Self {
compile_ms: 0,
exec_ms: 0,
total_ms: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScriptErrorInfo {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<ScriptLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backtrace: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScriptImageInfo {
pub id: String,
pub content_index: usize,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rect: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct ScriptValue {
pub(super) value: Option<Value>,
pub(super) images: Option<Vec<ScriptImageInfo>>,
pub(super) content: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptEvalOutcome {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<ScriptImageInfo>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub logs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub assertions: Vec<ScriptAssertion>,
pub timing: ScriptTiming,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ScriptErrorInfo>,
#[serde(skip)]
pub(super) content: Vec<ContentBlock>,
}
impl ScriptEvalOutcome {
pub(crate) fn to_tool_result(&self) -> CallToolResult {
let mut result = match CallToolResult::new().with_json_text(self) {
Ok(result) => result,
Err(error) => {
let fallback = serde_json::json!({
"success": false,
"value": null,
"logs": [],
"assertions": [],
"timing": ScriptTiming::zero(),
"error": {
"type": "runtime",
"message": format!("Failed to serialize output: {error}"),
},
});
CallToolResult::new()
.with_json_text(&fallback)
.unwrap_or_else(|_| {
CallToolResult::new().with_text_content(
r#"{"success":false,"error":{"type":"runtime","message":"Failed to serialize output"}}"#,
)
})
}
};
for block in &self.content {
result = result.with_content(block.clone());
}
result
}
pub(crate) fn error_only(error: ScriptErrorInfo) -> Self {
Self {
success: false,
value: None,
images: None,
logs: Vec::new(),
assertions: Vec::new(),
timing: ScriptTiming::zero(),
error: Some(error),
content: Vec::new(),
}
}
}
impl From<ScriptEvalOutcome> for CallToolResult {
fn from(outcome: ScriptEvalOutcome) -> Self {
outcome.to_tool_result()
}
}
#[derive(Debug, Clone)]
pub(super) enum ScriptImageKind {
Viewport,
Widget,
}
impl ScriptImageKind {
pub(super) fn as_str(&self) -> &'static str {
match self {
Self::Viewport => "viewport",
Self::Widget => "widget",
}
}
}
#[derive(Debug, Clone)]
pub(super) struct ImageCapture {
pub(super) id: String,
pub(super) data: String,
pub(super) kind: ScriptImageKind,
pub(super) viewport_id: String,
pub(super) target: Option<WidgetRef>,
pub(super) rect: Option<Rect>,
}
#[derive(Debug, Default)]
pub(super) struct ImageReferenceCollector {
pub(super) used: Vec<String>,
seen: HashSet<String>,
}
impl ImageReferenceCollector {
pub(super) fn record(&mut self, id: &str) {
if self.seen.insert(id.to_string()) {
self.used.push(id.to_string());
}
}
pub(super) fn contains(&self, id: &str) -> bool {
self.seen.contains(id)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{ScriptArgValue, ScriptEvalOptions};
#[test]
fn script_eval_options_default_args_to_empty_map() {
let options: ScriptEvalOptions =
serde_json::from_value(json!({ "source_name": "test.luau" })).expect("options");
assert_eq!(options.source_name.as_deref(), Some("test.luau"));
assert!(options.args.is_empty());
}
#[test]
fn script_eval_options_round_trip_scalar_args() {
let input = json!({
"source_name": "test.luau",
"args": {
"name": "Sky",
"count": 4,
"ratio": 1.5,
"enabled": true
}
});
let options: ScriptEvalOptions = serde_json::from_value(input.clone()).expect("options");
assert_eq!(
options.args["name"],
ScriptArgValue::String("Sky".to_string())
);
assert_eq!(options.args["count"], ScriptArgValue::Int(4));
assert_eq!(options.args["ratio"], ScriptArgValue::Float(1.5));
assert_eq!(options.args["enabled"], ScriptArgValue::Bool(true));
assert_eq!(serde_json::to_value(options).expect("serialize"), input);
}
#[test]
fn script_eval_options_reject_invalid_arg_shapes() {
for invalid in [
json!({ "args": null }),
json!({ "args": { "bad": [1, 2, 3] } }),
json!({ "args": { "bad": { "nested": true } } }),
] {
let error = serde_json::from_value::<ScriptEvalOptions>(invalid).expect_err("invalid");
assert!(!error.to_string().is_empty());
}
}
}