use plushie_core::Selector;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Header {
pub app: Option<String>,
pub viewport: (u32, u32),
pub backend: String,
}
impl Default for Header {
fn default() -> Self {
Self {
app: None,
viewport: (800, 600),
backend: "mock".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Instruction {
Click(Selector),
TypeText(Selector, String),
TypeKey(String),
Press(String),
Release(String),
Toggle(Selector, Option<bool>),
Select(Selector, String),
Slide(Selector, f64),
MoveTo(f32, f32),
MoveToSelector(Selector),
Scroll(Selector, f32, f32),
Wait(u64),
Expect(String),
AssertText(Selector, String),
AssertExists(Selector),
AssertNotExists(Selector),
AssertModel(String),
Screenshot(String),
TreeHash(String),
}
#[derive(Debug, Clone)]
pub struct PlushieFile {
pub header: Header,
pub instructions: Vec<(usize, Instruction)>,
}
pub fn parse(content: &str) -> Result<PlushieFile, String> {
let mut lines = content.lines().enumerate();
let mut header = Header::default();
let mut found_separator = false;
for (line_no, line) in &mut lines {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("-----") {
found_separator = true;
break;
}
if let Some((key, value)) = trimmed.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"app" => header.app = Some(value.to_string()),
"viewport" => {
header.viewport = parse_viewport(value)
.map_err(|e| format!("line {}: viewport: {e}", line_no + 1))?;
}
"backend" => header.backend = value.to_string(),
_ => {} }
} else {
return Err(format!(
"line {}: expected 'key: value' or '-----'",
line_no + 1
));
}
}
if !found_separator {
return Err("missing '-----' separator between header and instructions".to_string());
}
let mut instructions = Vec::new();
for (line_no, line) in lines {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let tokens = tokenize(trimmed);
if tokens.is_empty() {
continue;
}
let instr = parse_instruction(&tokens).map_err(|e| format!("line {}: {e}", line_no + 1))?;
instructions.push((line_no + 1, instr));
}
Ok(PlushieFile {
header,
instructions,
})
}
pub fn parse_file(path: &str) -> Result<PlushieFile, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("failed to read {path}: {e}"))?;
parse(&content)
}
const MIN_VIEWPORT: u32 = 64;
const MAX_VIEWPORT: u32 = 32767;
fn parse_viewport(s: &str) -> Result<(u32, u32), String> {
let (w, h) = s
.split_once('x')
.ok_or_else(|| format!("expected 'WxH' format, got '{s}'"))?;
let w: u32 = w.parse().map_err(|_| format!("invalid width '{w}'"))?;
let h: u32 = h.parse().map_err(|_| format!("invalid height '{h}'"))?;
if !(MIN_VIEWPORT..=MAX_VIEWPORT).contains(&w) {
return Err(format!(
"viewport width {w} out of range {MIN_VIEWPORT}..={MAX_VIEWPORT}"
));
}
if !(MIN_VIEWPORT..=MAX_VIEWPORT).contains(&h) {
return Err(format!(
"viewport height {h} out of range {MIN_VIEWPORT}..={MAX_VIEWPORT}"
));
}
Ok((w, h))
}
fn sel(s: &str) -> Selector {
Selector::from(s)
}
fn tokenize(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = line.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
chars.next(); let mut token = String::new();
for c in chars.by_ref() {
if c == '"' {
break;
}
token.push(c);
}
tokens.push(token);
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
token.push(c);
chars.next();
}
tokens.push(token);
}
}
tokens
}
fn parse_instruction(tokens: &[String]) -> Result<Instruction, String> {
let cmd = tokens[0].as_str();
let args = &tokens[1..];
match cmd {
"click" => {
require_args(cmd, args, 1)?;
Ok(Instruction::Click(sel(&args[0])))
}
"type" => {
if args.len() == 1 {
Ok(Instruction::TypeKey(args[0].clone()))
} else if args.len() >= 2 {
Ok(Instruction::TypeText(sel(&args[0]), args[1].clone()))
} else {
Err("type requires 1 or 2 arguments".to_string())
}
}
"press" => {
require_args(cmd, args, 1)?;
Ok(Instruction::Press(args[0].clone()))
}
"release" => {
require_args(cmd, args, 1)?;
Ok(Instruction::Release(args[0].clone()))
}
"toggle" => {
if args.is_empty() {
return Err("toggle requires at least 1 argument".to_string());
}
let value = args.get(1).map(|v| v == "true");
Ok(Instruction::Toggle(sel(&args[0]), value))
}
"select" => {
require_args(cmd, args, 2)?;
Ok(Instruction::Select(sel(&args[0]), args[1].clone()))
}
"slide" => {
require_args(cmd, args, 2)?;
let value: f64 = args[1]
.parse()
.map_err(|_| format!("slide: invalid number '{}'", args[1]))?;
Ok(Instruction::Slide(sel(&args[0]), value))
}
"scroll" => {
require_args(cmd, args, 3)?;
let dx: f32 = args[1]
.parse()
.map_err(|_| format!("scroll: invalid dx '{}'", args[1]))?;
let dy: f32 = args[2]
.parse()
.map_err(|_| format!("scroll: invalid dy '{}'", args[2]))?;
Ok(Instruction::Scroll(sel(&args[0]), dx, dy))
}
"move" => {
require_args(cmd, args, 1)?;
if let Some((x_str, y_str)) = args[0].split_once(',') {
let x: f32 = x_str
.parse()
.map_err(|_| format!("move: invalid x '{x_str}'"))?;
let y: f32 = y_str
.parse()
.map_err(|_| format!("move: invalid y '{y_str}'"))?;
Ok(Instruction::MoveTo(x, y))
} else {
Ok(Instruction::MoveToSelector(sel(&args[0])))
}
}
"wait" => {
require_args(cmd, args, 1)?;
let ms: u64 = args[0]
.parse()
.map_err(|_| format!("wait: invalid duration '{}'", args[0]))?;
Ok(Instruction::Wait(ms))
}
"expect" => {
require_args(cmd, args, 1)?;
Ok(Instruction::Expect(args[0].clone()))
}
"assert_text" => {
require_args(cmd, args, 2)?;
Ok(Instruction::AssertText(sel(&args[0]), args[1].clone()))
}
"assert_exists" => {
require_args(cmd, args, 1)?;
Ok(Instruction::AssertExists(sel(&args[0])))
}
"assert_not_exists" => {
require_args(cmd, args, 1)?;
Ok(Instruction::AssertNotExists(sel(&args[0])))
}
"assert_model" => {
require_args(cmd, args, 1)?;
Ok(Instruction::AssertModel(args[0].clone()))
}
"screenshot" => {
require_args(cmd, args, 1)?;
Ok(Instruction::Screenshot(args[0].clone()))
}
"tree_hash" => {
require_args(cmd, args, 1)?;
Ok(Instruction::TreeHash(args[0].clone()))
}
_ => Err(format!("unknown instruction '{cmd}'")),
}
}
fn require_args(cmd: &str, args: &[String], n: usize) -> Result<(), String> {
if args.len() < n {
Err(format!(
"{cmd} requires {n} argument(s), got {}",
args.len()
))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_header_and_instructions() {
let content = "app: Counter\nviewport: 800x600\n-----\nclick \"#inc\"\nassert_text \"#count\" \"1\"\n";
let file = parse(content).unwrap();
assert_eq!(file.header.app.as_deref(), Some("Counter"));
assert_eq!(file.header.viewport, (800, 600));
assert_eq!(file.instructions.len(), 2);
}
#[test]
fn parse_ignores_comments_and_blanks() {
let content = "app: Test\n-----\n# comment\n\nclick \"#btn\"\n";
let file = parse(content).unwrap();
assert_eq!(file.instructions.len(), 1);
}
#[test]
fn tokenize_quoted_and_bare() {
let tokens = tokenize("click \"#my btn\"");
assert_eq!(tokens, vec!["click", "#my btn"]);
}
#[test]
fn tokenize_multiple_args() {
let tokens = tokenize("type \"#input\" \"hello world\"");
assert_eq!(tokens, vec!["type", "#input", "hello world"]);
}
#[test]
fn parse_type_key() {
let content = "app: T\n-----\ntype enter\n";
let file = parse(content).unwrap();
assert_eq!(file.instructions[0].1, Instruction::TypeKey("enter".into()));
}
#[test]
fn parse_toggle_with_value() {
let content = "app: T\n-----\ntoggle \"#cb\" true\n";
let file = parse(content).unwrap();
assert_eq!(
file.instructions[0].1,
Instruction::Toggle(Selector::id("#cb"), Some(true))
);
}
#[test]
fn parse_move_coordinates() {
let content = "app: T\n-----\nmove 100,200\n";
let file = parse(content).unwrap();
assert_eq!(file.instructions[0].1, Instruction::MoveTo(100.0, 200.0));
}
#[test]
fn parse_slide() {
let content = "app: T\n-----\nslide \"#vol\" 0.75\n";
let file = parse(content).unwrap();
assert_eq!(
file.instructions[0].1,
Instruction::Slide(Selector::id("#vol"), 0.75)
);
}
#[test]
fn missing_separator_is_error() {
let result = parse("app: Test\nclick \"#btn\"\n");
assert!(result.is_err());
}
#[test]
fn header_defaults() {
let content = "-----\nclick \"#a\"\n";
let file = parse(content).unwrap();
assert_eq!(file.header.app, None);
assert_eq!(file.header.viewport, (800, 600));
assert_eq!(file.header.backend, "mock");
}
#[test]
fn invalid_viewport_is_error() {
let result = parse("viewport: bad\n-----\n");
assert!(result.is_err());
assert!(result.unwrap_err().contains("viewport"));
}
#[test]
fn viewport_too_small_rejected() {
let result = parse("viewport: 10x10\n-----\n");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("viewport") && err.contains("out of range"));
}
#[test]
fn viewport_too_large_rejected() {
let result = parse("viewport: 1000000x100\n-----\n");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("viewport") && err.contains("out of range"));
}
#[test]
fn viewport_in_range_accepted() {
let file = parse("viewport: 800x600\n-----\n").unwrap();
assert_eq!(file.header.viewport, (800, 600));
}
#[test]
fn unknown_header_keys_ignored() {
let content = "app: T\ncustom_key: value\n-----\nclick \"#a\"\n";
let file = parse(content).unwrap();
assert_eq!(file.header.app.as_deref(), Some("T"));
}
}