use agentchrome::error::{AppError, ExitCode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub commands: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Step {
Cmd(CmdStep),
If(IfStep),
Loop(LoopStep),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CmdStep {
pub cmd: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bind: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IfStep {
pub r#if: String,
pub then: Vec<Step>,
#[serde(default)]
pub r#else: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopStep {
pub r#loop: LoopKind,
pub body: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum LoopKind {
Count(CountLoop),
While(WhileLoop),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CountLoop {
pub count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhileLoop {
pub r#while: String,
pub max: u64,
}
pub fn parse_script(bytes: &[u8]) -> Result<Script, AppError> {
let script: Script = serde_json::from_slice(bytes).map_err(|e| AppError {
message: format!("script parse error: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
validate_script(&script)?;
Ok(script)
}
fn validate_script(script: &Script) -> Result<(), AppError> {
if script.commands.is_empty() {
return Err(AppError {
message: "script 'commands' array must not be empty".into(),
code: ExitCode::GeneralError,
custom_json: None,
});
}
for (i, step) in script.commands.iter().enumerate() {
validate_step(step, i)?;
}
Ok(())
}
fn validate_step(step: &Step, index: usize) -> Result<(), AppError> {
match step {
Step::Cmd(cmd_step) => {
if cmd_step.cmd.is_empty() {
return Err(AppError {
message: format!("script step {index}: 'cmd' array must not be empty"),
code: ExitCode::GeneralError,
custom_json: None,
});
}
if let Some(name) = &cmd_step.bind
&& !is_valid_identifier(name)
{
return Err(AppError {
message: format!(
"script step {index}: 'bind' name '{name}' is not a valid identifier \
(must match [a-zA-Z_][a-zA-Z0-9_]*)"
),
code: ExitCode::GeneralError,
custom_json: None,
});
}
}
Step::If(if_step) => {
if if_step.r#if.trim().is_empty() {
return Err(AppError {
message: format!("script step {index}: 'if' expression must not be empty"),
code: ExitCode::GeneralError,
custom_json: None,
});
}
for (j, sub) in if_step.then.iter().enumerate() {
validate_step(sub, j)?;
}
for (j, sub) in if_step.r#else.iter().enumerate() {
validate_step(sub, j)?;
}
}
Step::Loop(loop_step) => {
match &loop_step.r#loop {
LoopKind::While(wl) => {
if wl.r#while.trim().is_empty() {
return Err(AppError {
message: format!(
"script step {index}: 'while' expression must not be empty"
),
code: ExitCode::GeneralError,
custom_json: None,
});
}
if wl.max == 0 {
return Err(AppError {
message: format!(
"script step {index}: 'max' must be >= 1 for while loops"
),
code: ExitCode::GeneralError,
custom_json: None,
});
}
}
LoopKind::Count(_) => {}
}
if loop_step.body.is_empty() {
return Err(AppError {
message: format!("script step {index}: 'body' array must not be empty"),
code: ExitCode::GeneralError,
custom_json: None,
});
}
for (j, sub) in loop_step.body.iter().enumerate() {
validate_step(sub, j)?;
}
}
}
Ok(())
}
fn is_valid_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
fn json(s: &str) -> Vec<u8> {
s.as_bytes().to_vec()
}
#[test]
fn parse_cmd_step() {
let bytes = json(r#"{"commands":[{"cmd":["navigate","https://example.com"]}]}"#);
let script = parse_script(&bytes).expect("should parse");
assert_eq!(script.commands.len(), 1);
match &script.commands[0] {
Step::Cmd(c) => {
assert_eq!(c.cmd, vec!["navigate", "https://example.com"]);
assert!(c.bind.is_none());
}
_ => panic!("expected Cmd"),
}
}
#[test]
fn parse_cmd_step_with_bind() {
let bytes = json(r#"{"commands":[{"cmd":["js","exec","document.title"],"bind":"title"}]}"#);
let script = parse_script(&bytes).expect("should parse");
match &script.commands[0] {
Step::Cmd(c) => assert_eq!(c.bind.as_deref(), Some("title")),
_ => panic!("expected Cmd"),
}
}
#[test]
fn parse_if_step() {
let bytes = json(
r#"{"commands":[{"if":"true","then":[{"cmd":["navigate","https://a.com"]}],"else":[]}]}"#,
);
let script = parse_script(&bytes).expect("should parse");
match &script.commands[0] {
Step::If(i) => {
assert_eq!(i.r#if, "true");
assert_eq!(i.then.len(), 1);
assert!(i.r#else.is_empty());
}
_ => panic!("expected If"),
}
}
#[test]
fn parse_count_loop() {
let bytes = json(
r#"{"commands":[{"loop":{"count":3},"body":[{"cmd":["navigate","https://a.com"]}]}]}"#,
);
let script = parse_script(&bytes).expect("should parse");
match &script.commands[0] {
Step::Loop(l) => match &l.r#loop {
LoopKind::Count(c) => assert_eq!(c.count, 3),
LoopKind::While(_) => panic!("expected Count"),
},
_ => panic!("expected Loop"),
}
}
#[test]
fn parse_while_loop() {
let bytes = json(
r#"{"commands":[{"loop":{"while":"true","max":10},"body":[{"cmd":["navigate","https://a.com"]}]}]}"#,
);
let script = parse_script(&bytes).expect("should parse");
match &script.commands[0] {
Step::Loop(l) => match &l.r#loop {
LoopKind::While(w) => {
assert_eq!(w.r#while, "true");
assert_eq!(w.max, 10);
}
LoopKind::Count(_) => panic!("expected While"),
},
_ => panic!("expected Loop"),
}
}
#[test]
fn reject_empty_commands() {
let bytes = json(r#"{"commands":[]}"#);
let err = parse_script(&bytes).expect_err("should fail");
assert!(err.message.contains("must not be empty"));
}
#[test]
fn reject_empty_cmd_array() {
let bytes = json(r#"{"commands":[{"cmd":[]}]}"#);
let err = parse_script(&bytes).expect_err("should fail");
assert!(err.message.contains("'cmd' array must not be empty"));
}
#[test]
fn reject_while_without_max() {
let bytes =
json(r#"{"commands":[{"loop":{"while":"true"},"body":[{"cmd":["navigate","x"]}]}]}"#);
let err = parse_script(&bytes).expect_err("should fail");
assert!(!err.message.is_empty());
}
#[test]
fn reject_while_with_max_zero() {
let bytes = json(
r#"{"commands":[{"loop":{"while":"true","max":0},"body":[{"cmd":["navigate","x"]}]}]}"#,
);
let err = parse_script(&bytes).expect_err("should fail");
assert!(err.message.contains("max"));
}
#[test]
fn reject_empty_body() {
let bytes = json(r#"{"commands":[{"loop":{"count":3},"body":[]}]}"#);
let err = parse_script(&bytes).expect_err("should fail");
assert!(err.message.contains("'body' array must not be empty"));
}
#[test]
fn reject_invalid_bind_name() {
let bytes = json(r#"{"commands":[{"cmd":["js","exec","document.title"],"bind":"1bad"}]}"#);
let err = parse_script(&bytes).expect_err("should fail");
assert!(err.message.contains("valid identifier"));
}
#[test]
fn valid_identifier_check() {
assert!(is_valid_identifier("title"));
assert!(is_valid_identifier("_hidden"));
assert!(is_valid_identifier("my_var2"));
assert!(!is_valid_identifier("1bad"));
assert!(!is_valid_identifier(""));
assert!(!is_valid_identifier("bad-name"));
}
}