use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::types::{
Action, Playbook, PlaybookConfig, PlaybookDriver, PlaybookRenderRowRef,
PlaybookRenderRowSegmentRef, PlaybookRenderTraceOp, RenderAssertion, ServiceKind,
SimTerminalEvent, SplitDirection, Step,
};
pub fn parse_dsl(input: &str) -> Result<(Playbook, Vec<(usize, String)>)> {
let mut config = PlaybookConfig::default();
let mut steps = Vec::new();
let mut step_index = 0_usize;
let mut includes: Vec<(usize, String)> = Vec::new();
for (line_num, raw_line) in input.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line_ctx = line_num + 1;
if let Some(directive) = line.strip_prefix('@') {
parse_config_directive(directive.trim(), &mut config, &mut includes, step_index)
.with_context(|| format!("line {line_ctx}: invalid config directive"))?;
} else {
let (action_line, continue_on_error) = line
.strip_suffix(" !continue")
.map_or((line, false), |stripped| (stripped.trim(), true));
let action = parse_action_line(action_line)
.with_context(|| format!("line {line_ctx}: invalid action"))?;
steps.push(Step {
index: step_index,
action,
continue_on_error,
});
step_index += 1;
}
}
Ok((Playbook { config, steps }, includes))
}
fn parse_config_directive(
directive: &str,
config: &mut PlaybookConfig,
includes: &mut Vec<(usize, String)>,
current_step_index: usize,
) -> Result<()> {
let (name, rest) = split_first_token(directive);
match name {
"viewport" => {
let args = parse_kv_args(rest)?;
if let Some(cols) = args.get("cols") {
config.viewport.cols = cols.parse().context("invalid cols")?;
}
if let Some(rows) = args.get("rows") {
config.viewport.rows = rows.parse().context("invalid rows")?;
}
}
"shell" => {
config.shell = Some(rest.trim().to_string());
}
"timeout" => {
let ms: u64 = rest.trim().parse().context("invalid timeout ms")?;
config.timeout = Duration::from_millis(ms);
}
"record" => {
config.record = rest.trim().parse::<bool>().unwrap_or(true);
}
"render-trace" => {
config.render_trace = rest.trim().parse::<bool>().unwrap_or(true);
}
"driver" => {
config.driver = match rest.trim() {
"sandbox" => PlaybookDriver::Sandbox,
"real-attach" => PlaybookDriver::RealAttach,
"attach-sim" => PlaybookDriver::AttachSim,
other => bail!(
"@driver must be 'sandbox', 'real-attach', or 'attach-sim', got '{other}'"
),
};
}
"name" => {
config.name = Some(rest.trim().to_string());
}
"description" => {
config.description = Some(rest.trim().to_string());
}
"plugin" => {
let args = parse_kv_args(rest)?;
if let Some(enable) = args.get("enable") {
config.plugins.enable.push(enable.clone());
}
if let Some(disable) = args.get("disable") {
config.plugins.disable.push(disable.clone());
}
}
"var" => {
let args = parse_kv_args(rest)?;
for (key, value) in &args {
config.vars.insert(key.clone(), value.clone());
}
if args.is_empty() {
if let Some(eq) = rest.find('=') {
let key = rest[..eq].trim().to_string();
let value = rest[eq + 1..].trim().to_string();
config.vars.insert(key, value);
} else {
bail!("@var requires NAME=VALUE format");
}
}
}
"env" => {
let args = parse_kv_args(rest)?;
for (key, value) in &args {
config.env.insert(key.clone(), value.clone());
}
if args.is_empty() {
if let Some(eq) = rest.find('=') {
let key = rest[..eq].trim().to_string();
let value = rest[eq + 1..].trim().to_string();
config.env.insert(key, value);
} else {
bail!("@env requires NAME=VALUE format");
}
}
}
"env-mode" => {
let mode = rest.trim();
config.env_mode = Some(match mode {
"inherit" => super::types::SandboxEnvMode::Inherit,
"clean" => super::types::SandboxEnvMode::Clean,
other => bail!("@env-mode must be 'inherit' or 'clean', got '{other}'"),
});
}
"include" => {
let path = rest.trim().to_string();
if path.is_empty() {
bail!("@include requires a file path");
}
includes.push((current_step_index, path));
}
_ => bail!("unknown config directive: @{name}"),
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub fn parse_action_line(line: &str) -> Result<Action> {
let (action_name, rest) = split_first_token(line);
let args = parse_kv_args(rest)?;
match action_name {
"new-session" => Ok(Action::NewSession {
name: args.get("name").cloned(),
}),
"kill-session" => {
let name = require_arg(&args, "name", "kill-session")?;
Ok(Action::KillSession { name })
}
"split-pane" => {
let direction = match args.get("direction").map(String::as_str) {
Some("vertical" | "v") | None => SplitDirection::Vertical,
Some("horizontal" | "h") => SplitDirection::Horizontal,
Some(other) => bail!("invalid split direction: {other}"),
};
let ratio = args
.get("ratio")
.map(|s| s.parse::<f64>())
.transpose()
.context("invalid ratio")?;
Ok(Action::SplitPane { direction, ratio })
}
"focus-pane" => {
let target: u32 = require_arg(&args, "target", "focus-pane")?
.parse()
.context("invalid target index")?;
Ok(Action::FocusPane { target })
}
"close-pane" => {
let target = args
.get("target")
.map(|s| s.parse::<u32>())
.transpose()
.context("invalid target index")?;
Ok(Action::ClosePane { target })
}
"send-keys" => {
let raw = require_arg(&args, "keys", "send-keys")?;
let bytes = decode_c_escapes(&raw)?;
let pane = args
.get("pane")
.map(|s| s.parse::<u32>())
.transpose()
.context("invalid pane index")?;
Ok(Action::SendKeys { keys: bytes, pane })
}
"send-bytes" => {
let hex_str = require_arg(&args, "hex", "send-bytes")?;
let bytes = decode_hex(&hex_str)?;
Ok(Action::SendBytes { hex: bytes })
}
"wait-for" => {
let pattern = require_arg(&args, "pattern", "wait-for")?;
let pane = args
.get("pane")
.map(|s| s.parse::<u32>())
.transpose()
.context("invalid pane index")?;
let timeout_ms: u64 = args
.get("timeout")
.map(|s| s.parse())
.transpose()
.context("invalid timeout")?
.unwrap_or(5000);
let retry: u32 = args
.get("retry")
.map(|s| s.parse())
.transpose()
.context("invalid retry count")?
.unwrap_or(1);
Ok(Action::WaitFor {
pattern,
pane,
timeout: Duration::from_millis(timeout_ms),
retry,
})
}
"sleep" => {
let ms: u64 = require_arg(&args, "ms", "sleep")?
.parse()
.context("invalid ms")?;
Ok(Action::Sleep {
duration: Duration::from_millis(ms),
})
}
"snapshot" => {
let id = require_arg(&args, "id", "snapshot")?;
Ok(Action::Snapshot { id })
}
"assert-screen" => {
let pane = args
.get("pane")
.map(|s| s.parse::<u32>())
.transpose()
.context("invalid pane index")?;
let contains = args.get("contains").cloned();
let not_contains = args.get("not_contains").cloned();
let matches = args.get("matches").cloned();
let scrollback = args
.get("scrollback")
.is_some_and(|value| value == "true" || value == "1");
if contains.is_none() && not_contains.is_none() && matches.is_none() {
bail!("assert-screen requires at least one of: contains, not_contains, matches");
}
Ok(Action::AssertScreen {
pane,
contains,
not_contains,
matches,
scrollback,
})
}
"assert-layout" => {
let pane_count = args
.get("pane_count")
.context("assert-layout requires pane_count")?
.parse::<u32>()
.context("invalid pane_count")?;
Ok(Action::AssertLayout { pane_count })
}
"assert-cursor" => {
let pane = args
.get("pane")
.map(|s| s.parse::<u32>())
.transpose()
.context("invalid pane index")?;
let row: u16 = require_arg(&args, "row", "assert-cursor")?
.parse()
.context("invalid row")?;
let col: u16 = require_arg(&args, "col", "assert-cursor")?
.parse()
.context("invalid col")?;
Ok(Action::AssertCursor { pane, row, col })
}
"resize-viewport" => {
let cols: u16 = require_arg(&args, "cols", "resize-viewport")?
.parse()
.context("invalid cols")?;
let rows: u16 = require_arg(&args, "rows", "resize-viewport")?
.parse()
.context("invalid rows")?;
Ok(Action::ResizeViewport { cols, rows })
}
"send-attach" => {
let key = require_arg(&args, "key", "send-attach")?;
Ok(Action::SendAttach { key })
}
"prefix-key" => {
let key_str = require_arg(&args, "key", "prefix-key")?;
let key = key_str.chars().next().context("empty key")?;
Ok(Action::PrefixKey { key })
}
"wait-for-event" => {
let event = require_arg(&args, "event", "wait-for-event")?;
let timeout_ms: u64 = args
.get("timeout")
.map(|s| s.parse())
.transpose()
.context("invalid timeout")?
.unwrap_or(5000);
Ok(Action::WaitForEvent {
event,
timeout: Duration::from_millis(timeout_ms),
})
}
"invoke-service" => {
let capability = require_arg(&args, "capability", "invoke-service")?;
let kind = match args.get("kind").map(String::as_str) {
Some("query" | "q") => ServiceKind::Query,
Some("command" | "cmd") | None => ServiceKind::Command,
Some(other) => {
bail!("invalid service kind: {other} (expected 'query' or 'command')")
}
};
let interface_id = require_arg(&args, "interface", "invoke-service")?;
let operation = require_arg(&args, "operation", "invoke-service")?;
let payload = args.get("payload").cloned().unwrap_or_default();
Ok(Action::InvokeService {
capability,
kind,
interface_id,
operation,
payload,
})
}
"screen" => Ok(Action::Screen),
"status" => Ok(Action::Status),
"render-mark" => {
let id = require_arg(&args, "id", "render-mark")?;
Ok(Action::RenderMark { id })
}
"assert-render" => {
let since = require_arg(&args, "since", "assert-render")?;
Ok(Action::AssertRender {
since,
assertion: parse_render_assertion(&args)?,
})
}
"seed-window-list" => {
let names = require_arg(&args, "names", "seed-window-list")?
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>();
let active = require_arg(&args, "active", "seed-window-list")?;
Ok(Action::SeedWindowList { names, active })
}
"seed-pane-text" => {
let lines = require_arg(&args, "lines", "seed-pane-text")?
.split('|')
.map(ToString::to_string)
.collect::<Vec<_>>();
let cursor_row = require_arg(&args, "cursor_row", "seed-pane-text")?.parse()?;
let cursor_col = require_arg(&args, "cursor_col", "seed-pane-text")?.parse()?;
Ok(Action::SeedPaneText {
lines,
cursor_row,
cursor_col,
})
}
"seed-pane-layout" => Ok(Action::SeedPaneLayout {
split: require_arg(&args, "split", "seed-pane-layout")?,
}),
"render" => Ok(Action::Render),
"locate" => Ok(Action::Locate {
id: require_arg(&args, "id", "locate")?,
text: require_arg(&args, "text", "locate")?,
}),
"terminal-event" => Ok(Action::TerminalEvent(SimTerminalEvent {
kind: require_arg(&args, "kind", "terminal-event")?,
phase: require_arg(&args, "phase", "terminal-event")?,
button: args.get("button").cloned(),
col: require_arg(&args, "col", "terminal-event")?,
row: require_arg(&args, "row", "terminal-event")?,
})),
"assert-effect" => Ok(Action::AssertEffect {
operation: require_arg(&args, "operation", "assert-effect")?,
}),
"assert-no-effect" => Ok(Action::AssertNoEffect {
operation: require_arg(&args, "operation", "assert-no-effect")?,
}),
"assert-state" => Ok(Action::AssertState {
path: require_arg(&args, "path", "assert-state")?,
equals: require_arg(&args, "equals", "assert-state")?,
}),
"assert-rendered" => Ok(Action::AssertRendered {
contains: args.get("contains").cloned(),
matches: args.get("matches").cloned(),
}),
"set-config" => Ok(Action::SetConfig {
path: require_arg(&args, "path", "set-config")?,
value: require_arg(&args, "value", "set-config")?,
}),
_ => bail!("unknown action: {action_name}"),
}
}
fn parse_render_assertion(args: &BTreeMap<String, String>) -> Result<RenderAssertion> {
let mut assertion = RenderAssertion::default();
macro_rules! parse_u64_opt {
($field:ident) => {
assertion.$field = args
.get(stringify!($field))
.map(|value| value.parse::<u64>())
.transpose()
.with_context(|| format!("invalid {}", stringify!($field)))?;
};
}
macro_rules! parse_bool_opt {
($field:ident) => {
assertion.$field = args
.get(stringify!($field))
.map(|value| value.parse::<bool>())
.transpose()
.with_context(|| format!("invalid {}", stringify!($field)))?;
};
}
parse_u64_opt!(min_frames);
parse_u64_opt!(max_frames);
parse_bool_opt!(full_frame);
parse_u64_opt!(max_full_frame_frames);
parse_u64_opt!(max_full_surface_fallbacks);
parse_u64_opt!(max_damage_rects);
parse_u64_opt!(max_damage_area_cells);
parse_u64_opt!(max_rows_emitted);
parse_u64_opt!(max_row_segments_emitted);
parse_u64_opt!(max_cells_emitted);
parse_u64_opt!(max_frame_bytes);
parse_bool_opt!(status_rendered);
parse_bool_opt!(overlay_rendered);
assertion.expected_emitted_rows = args
.get("expected_emitted_rows")
.map(|value| parse_render_row_refs(value))
.transpose()?;
assertion.expected_emitted_row_segments = args
.get("expected_emitted_row_segments")
.map(|value| parse_render_row_segment_refs(value))
.transpose()?;
assertion.expected_trace_ops = args
.get("expected_trace_ops")
.map(|value| parse_render_trace_ops(value))
.transpose()?;
Ok(assertion)
}
fn parse_render_row_refs(value: &str) -> Result<Vec<PlaybookRenderRowRef>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
trimmed
.split(',')
.map(|entry| {
let (pane, row) = entry
.split_once(':')
.with_context(|| format!("invalid render row ref '{entry}', expected pane:row"))?;
Ok(PlaybookRenderRowRef {
pane: pane.parse().context("invalid render row pane")?,
row: row.parse().context("invalid render row index")?,
})
})
.collect()
}
fn parse_render_row_segment_refs(value: &str) -> Result<Vec<PlaybookRenderRowSegmentRef>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
trimmed
.split(',')
.map(|entry| {
let parts = entry.split(':').collect::<Vec<_>>();
if parts.len() != 4 {
bail!(
"invalid render row segment ref '{entry}', expected pane:row:start_col:cells"
);
}
Ok(PlaybookRenderRowSegmentRef {
pane: parts[0]
.parse()
.context("invalid render row segment pane")?,
row: parts[1].parse().context("invalid render row segment row")?,
start_col: parts[2]
.parse()
.context("invalid render row segment start_col")?,
cells: parts[3]
.parse()
.context("invalid render row segment cells")?,
})
})
.collect()
}
fn parse_render_trace_ops(value: &str) -> Result<Vec<PlaybookRenderTraceOp>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
trimmed
.split(',')
.map(|entry| PlaybookRenderTraceOp::parse_compact(entry).map_err(anyhow::Error::msg))
.collect()
}
fn require_arg(args: &BTreeMap<String, String>, key: &str, action: &str) -> Result<String> {
args.get(key)
.cloned()
.with_context(|| format!("{action} requires '{key}' argument"))
}
fn split_first_token(s: &str) -> (&str, &str) {
s.find(char::is_whitespace)
.map_or((s, ""), |pos| (&s[..pos], s[pos..].trim_start()))
}
fn parse_kv_args(input: &str) -> Result<BTreeMap<String, String>> {
let mut args = BTreeMap::new();
let mut remaining = input.trim();
while !remaining.is_empty() {
let eq_pos = remaining
.find('=')
.with_context(|| format!("expected key=value, got: {remaining}"))?;
let key = remaining[..eq_pos].trim().to_string();
remaining = remaining[eq_pos + 1..].trim_start();
let (value, rest) = parse_value(remaining)?;
args.insert(key, value);
remaining = rest.trim_start();
}
Ok(args)
}
fn parse_value(input: &str) -> Result<(String, &str)> {
if input.is_empty() {
return Ok((String::new(), ""));
}
let first = input.as_bytes()[0];
if first == b'\'' || first == b'"' {
parse_quoted_value(input, first as char)
} else {
input.find(char::is_whitespace).map_or_else(
|| Ok((input.to_string(), "")),
|pos| Ok((input[..pos].to_string(), &input[pos..])),
)
}
}
fn parse_quoted_value(input: &str, quote: char) -> Result<(String, &str)> {
let bytes = input.as_bytes();
debug_assert!(bytes[0] == quote as u8);
let quote_byte = quote as u8;
let mut result = Vec::new();
let mut i = 1;
while i < bytes.len() {
if bytes[i] == quote_byte {
return Ok((String::from_utf8(result)?, &input[i + 1..]));
}
if bytes[i] == b'\\' && i + 1 < bytes.len() && matches!(bytes[i + 1], b'\'' | b'"') {
result.push(bytes[i + 1]);
i += 2;
continue;
}
result.push(bytes[i]);
i += 1;
}
bail!("unterminated quoted string");
}
pub fn decode_c_escapes(input: &str) -> Result<Vec<u8>> {
let bytes = input.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 1;
match bytes[i] {
b'n' => result.push(b'\n'),
b'r' => result.push(b'\r'),
b't' => result.push(b'\t'),
b'\\' => result.push(b'\\'),
b'\'' => result.push(b'\''),
b'"' => result.push(b'"'),
b'0' => result.push(0),
b'a' => result.push(0x07),
b'b' => result.push(0x08),
b'e' => result.push(0x1b),
b'x' if i + 2 < bytes.len() => {
let hex = &input[i + 1..i + 3];
let byte = u8::from_str_radix(hex, 16)
.with_context(|| format!("invalid hex escape: \\x{hex}"))?;
result.push(byte);
i += 2;
}
other => {
result.push(b'\\');
result.push(other);
}
}
} else {
result.push(bytes[i]);
}
i += 1;
}
Ok(result)
}
fn decode_hex(hex: &str) -> Result<Vec<u8>> {
let hex = hex.trim();
if !hex.len().is_multiple_of(2) {
bail!("hex string must have even length");
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.with_context(|| format!("invalid hex at position {i}"))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_playbook() {
let input = r"
# A simple test playbook
@viewport cols=120 rows=50
@timeout 10000
@name test-playbook
new-session name=main
send-keys keys='echo hello\r'
wait-for pattern='hello' timeout=3000
sleep ms=100
snapshot id=final
";
let (playbook, _includes) = parse_dsl(input).unwrap();
assert_eq!(playbook.config.name.as_deref(), Some("test-playbook"));
assert_eq!(playbook.config.viewport.cols, 120);
assert_eq!(playbook.config.viewport.rows, 50);
assert_eq!(playbook.config.timeout, Duration::from_secs(10));
assert_eq!(playbook.steps.len(), 5);
assert_eq!(playbook.steps[0].action.name(), "new-session");
assert_eq!(playbook.steps[1].action.name(), "send-keys");
assert_eq!(playbook.steps[2].action.name(), "wait-for");
assert_eq!(playbook.steps[3].action.name(), "sleep");
assert_eq!(playbook.steps[4].action.name(), "snapshot");
}
#[test]
fn parse_send_keys_escapes() {
let input = "send-keys keys='hello\\r\\n'";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendKeys { keys, .. } => {
assert_eq!(keys, b"hello\r\n");
}
_ => panic!("expected send-keys"),
}
}
#[test]
fn parse_send_keys_preserves_double_escaped_shell_sequences() {
let input = r#"send-keys keys='printf \"\\e[2J\\e[H\\n\"\r'"#;
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendKeys { keys, .. } => {
assert_eq!(keys, b"printf \"\\e[2J\\e[H\\n\"\r");
}
_ => panic!("expected send-keys"),
}
}
#[test]
fn parse_send_keys_hex_escape() {
let input = "send-keys keys='\\x1b[A'";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendKeys { keys, .. } => {
assert_eq!(keys, b"\x1b[A");
}
_ => panic!("expected send-keys"),
}
}
#[test]
fn parse_send_bytes_hex() {
let input = "send-bytes hex=1b5b41";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendBytes { hex } => {
assert_eq!(hex, &[0x1b, 0x5b, 0x41]);
}
_ => panic!("expected send-bytes"),
}
}
#[test]
fn parse_split_pane_defaults() {
let input = "split-pane";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SplitPane { direction, ratio } => {
assert_eq!(*direction, SplitDirection::Vertical);
assert!(ratio.is_none());
}
_ => panic!("expected split-pane"),
}
}
#[test]
fn parse_plugin_config() {
let input = "@plugin enable=bmux.windows\n@plugin disable=bmux.permissions";
let (playbook, _includes) = parse_dsl(input).unwrap();
assert_eq!(playbook.config.plugins.enable, vec!["bmux.windows"]);
assert_eq!(playbook.config.plugins.disable, vec!["bmux.permissions"]);
}
#[test]
fn parse_assert_screen() {
let input = "assert-screen pane=0 contains='hello world'";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::AssertScreen {
pane,
contains,
not_contains,
matches,
scrollback,
} => {
assert_eq!(*pane, Some(0));
assert_eq!(contains.as_deref(), Some("hello world"));
assert!(not_contains.is_none());
assert!(matches.is_none());
assert!(!*scrollback);
}
_ => panic!("expected assert-screen"),
}
}
#[test]
fn decode_c_escapes_ctrl_a() {
let bytes = decode_c_escapes("\\x01").unwrap();
assert_eq!(bytes, vec![0x01]);
}
#[test]
fn decode_c_escapes_mixed() {
let bytes = decode_c_escapes("hello\\r\\nworld").unwrap();
assert_eq!(bytes, b"hello\r\nworld");
}
#[test]
fn empty_input_produces_empty_playbook() {
let (playbook, _) = parse_dsl("").unwrap();
assert!(playbook.steps.is_empty());
}
#[test]
fn comments_and_blanks_ignored() {
let input = "\n# comment\n \n# another comment\n";
let (playbook, _includes) = parse_dsl(input).unwrap();
assert!(playbook.steps.is_empty());
}
#[test]
fn unknown_action_fails() {
let result = parse_dsl("nonexistent-action foo=bar");
assert!(result.is_err());
}
#[test]
fn unknown_directive_fails() {
let result = parse_dsl("@bogus-directive something");
assert!(result.is_err());
}
#[test]
fn parse_resize_viewport() {
let input = "resize-viewport cols=132 rows=50";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::ResizeViewport { cols, rows } => {
assert_eq!(*cols, 132);
assert_eq!(*rows, 50);
}
_ => panic!("expected resize-viewport"),
}
}
#[test]
fn parse_prefix_key() {
let input = "prefix-key key=c";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::PrefixKey { key } => {
assert_eq!(*key, 'c');
}
_ => panic!("expected prefix-key"),
}
}
#[test]
fn parse_send_attach() {
let input = "send-attach key='ctrl+a ['";
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendAttach { key } => assert_eq!(key, "ctrl+a ["),
_ => panic!("expected send-attach"),
}
}
#[test]
fn double_quoted_value() {
let input = r#"send-keys keys="echo hello\r""#;
let (playbook, _includes) = parse_dsl(input).unwrap();
match &playbook.steps[0].action {
Action::SendKeys { keys, .. } => {
assert_eq!(keys, b"echo hello\r");
}
_ => panic!("expected send-keys"),
}
}
#[test]
fn parse_env_mode_clean() {
let input = "@env-mode clean\nnew-session\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert_eq!(
playbook.config.env_mode,
Some(super::super::types::SandboxEnvMode::Clean)
);
}
#[test]
fn parse_env_mode_inherit() {
let input = "@env-mode inherit\nnew-session\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert_eq!(
playbook.config.env_mode,
Some(super::super::types::SandboxEnvMode::Inherit)
);
}
#[test]
fn parse_env_mode_invalid_fails() {
let input = "@env-mode foobar\nnew-session\n";
assert!(parse_dsl(input).is_err());
}
#[test]
fn parse_env_directive() {
let input = "@env FOO=bar\n@env BAZ=qux\nnew-session\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert_eq!(playbook.config.env.get("FOO").unwrap(), "bar");
assert_eq!(playbook.config.env.get("BAZ").unwrap(), "qux");
}
#[test]
fn parse_screen_action() {
let input = "new-session\nscreen\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert!(matches!(playbook.steps[1].action, Action::Screen));
}
#[test]
fn parse_status_action() {
let input = "new-session\nstatus\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert!(matches!(playbook.steps[1].action, Action::Status));
}
#[test]
fn parse_wait_for_retry() {
let input = "new-session\nwait-for pattern='hello' retry=3\n";
let (playbook, _) = parse_dsl(input).unwrap();
match &playbook.steps[1].action {
Action::WaitFor { retry, .. } => assert_eq!(*retry, 3),
other => panic!("expected WaitFor, got {other:?}"),
}
}
#[test]
fn parse_continue_on_error_suffix() {
let input = "new-session\nassert-screen contains='hello' !continue\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert!(playbook.steps[1].continue_on_error);
}
#[test]
fn parse_no_continue_on_error_by_default() {
let input = "new-session\nassert-screen contains='hello'\n";
let (playbook, _) = parse_dsl(input).unwrap();
assert!(!playbook.steps[1].continue_on_error);
}
}