use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use super::parse_dsl::decode_c_escapes;
use super::types::{
Action, Playbook, PlaybookConfig, PlaybookRenderRowRef, PlaybookRenderRowSegmentRef,
PlaybookRenderTraceOp, PluginConfig, RenderAssertion, ServiceKind, SplitDirection, Step,
Viewport,
};
pub fn parse_toml(input: &str) -> Result<(Playbook, Vec<(usize, String)>)> {
let raw: RawPlaybook = toml::from_str(input).context("invalid playbook TOML")?;
let includes: Vec<(usize, String)> = raw
.playbook
.as_ref()
.and_then(|p| p.include.clone())
.unwrap_or_default()
.into_iter()
.map(|path| (0, path)) .collect();
let config = parse_config(raw.playbook)?;
let steps = raw
.step
.unwrap_or_default()
.into_iter()
.enumerate()
.map(|(i, raw_step)| {
let coe = raw_step.continue_on_error.unwrap_or(false);
let action =
parse_step_action(raw_step).with_context(|| format!("step {}: invalid", i + 1))?;
Ok(Step {
index: i,
action,
continue_on_error: coe,
})
})
.collect::<Result<Vec<_>>>()?;
Ok((Playbook { config, steps }, includes))
}
fn parse_config(raw: Option<RawPlaybookConfig>) -> Result<PlaybookConfig> {
let Some(raw) = raw else {
return Ok(PlaybookConfig::default());
};
let viewport = raw.viewport.map_or_else(Viewport::default, |v| {
let defaults = Viewport::default();
Viewport {
cols: v.cols.unwrap_or(defaults.cols),
rows: v.rows.unwrap_or(defaults.rows),
}
});
let timeout = raw
.timeout_ms
.map_or(Duration::from_secs(30), Duration::from_millis);
let plugins = match raw.plugins {
Some(p) => PluginConfig {
enable: p.enable.unwrap_or_default(),
disable: p.disable.unwrap_or_default(),
},
None => PluginConfig::default(),
};
let env_mode = match raw.env_mode.as_deref() {
Some("clean") => Some(super::types::SandboxEnvMode::Clean),
Some("inherit") => Some(super::types::SandboxEnvMode::Inherit),
None => None,
Some(other) => bail!("env_mode must be 'inherit' or 'clean', got '{other}'"),
};
Ok(PlaybookConfig {
name: raw.name,
description: raw.description,
viewport,
shell: raw.shell,
timeout,
record: raw.record.unwrap_or(false),
plugins,
vars: raw.vars.unwrap_or_default(),
env: raw.env.unwrap_or_default(),
env_mode,
binary: None,
bundled_plugin_ids: Vec::new(),
verbose: false,
render_trace: raw.render_trace.unwrap_or(false),
driver: super::types::PlaybookDriver::Sandbox,
})
}
#[allow(clippy::too_many_lines)]
fn parse_step_action(step: RawStep) -> Result<Action> {
match step.action.as_str() {
"new-session" => Ok(Action::NewSession { name: step.name }),
"kill-session" => {
let name = step.name.context("kill-session requires 'name'")?;
Ok(Action::KillSession { name })
}
"split-pane" => {
let direction = match step.direction.as_deref() {
Some("vertical" | "v") | None => SplitDirection::Vertical,
Some("horizontal" | "h") => SplitDirection::Horizontal,
Some(other) => bail!("invalid split direction: {other}"),
};
Ok(Action::SplitPane {
direction,
ratio: step.ratio,
})
}
"focus-pane" => {
let target = step.target.context("focus-pane requires 'target'")?;
Ok(Action::FocusPane { target })
}
"close-pane" => Ok(Action::ClosePane {
target: step.target,
}),
"send-keys" => {
let raw = step.keys.context("send-keys requires 'keys'")?;
let bytes = decode_c_escapes(&raw)?;
Ok(Action::SendKeys {
keys: bytes,
pane: step.pane,
})
}
"send-bytes" => {
let hex_str = step.hex.context("send-bytes requires 'hex'")?;
let bytes = decode_hex(&hex_str)?;
Ok(Action::SendBytes { hex: bytes })
}
"wait-for" => {
let pattern = step.pattern.context("wait-for requires 'pattern'")?;
let timeout_ms = step.timeout_ms.unwrap_or(5000);
Ok(Action::WaitFor {
pattern,
pane: step.pane,
timeout: Duration::from_millis(timeout_ms),
retry: step.retry.unwrap_or(1),
})
}
"sleep" => {
let ms = step.ms.context("sleep requires 'ms'")?;
Ok(Action::Sleep {
duration: Duration::from_millis(ms),
})
}
"snapshot" => {
let id = step.id.context("snapshot requires 'id'")?;
Ok(Action::Snapshot { id })
}
"assert-screen" => {
if step.contains.is_none() && step.not_contains.is_none() && step.matches.is_none() {
bail!("assert-screen requires at least one of: contains, not_contains, matches");
}
Ok(Action::AssertScreen {
pane: step.pane,
contains: step.contains,
not_contains: step.not_contains,
matches: step.matches,
scrollback: step.scrollback.unwrap_or(false),
})
}
"assert-layout" => {
let pane_count = step
.pane_count
.context("assert-layout requires pane_count")?;
Ok(Action::AssertLayout { pane_count })
}
"assert-cursor" => {
let row = step.row.context("assert-cursor requires 'row'")?;
let col = step.col.context("assert-cursor requires 'col'")?;
Ok(Action::AssertCursor {
pane: step.pane,
row,
col,
})
}
"resize-viewport" => {
let cols = step.cols.context("resize-viewport requires 'cols'")?;
let rows = step.rows.context("resize-viewport requires 'rows'")?;
Ok(Action::ResizeViewport { cols, rows })
}
"send-attach" => {
let key = step.key.context("send-attach requires 'key'")?;
Ok(Action::SendAttach { key })
}
"seed-pane-text" => {
let lines = step.lines.context("seed-pane-text requires 'lines'")?;
let cursor_row = step
.cursor_row
.context("seed-pane-text requires 'cursor_row'")?;
let cursor_col = step
.cursor_col
.context("seed-pane-text requires 'cursor_col'")?;
Ok(Action::SeedPaneText {
lines,
cursor_row,
cursor_col,
})
}
"seed-pane-layout" => {
let split = step.split.context("seed-pane-layout requires 'split'")?;
Ok(Action::SeedPaneLayout { split })
}
"prefix-key" => {
let key_str = step.key.context("prefix-key requires 'key'")?;
let key = key_str.chars().next().context("empty key")?;
Ok(Action::PrefixKey { key })
}
"wait-for-event" => {
let event = step.event.context("wait-for-event requires 'event'")?;
let timeout_ms = step.timeout_ms.unwrap_or(5000);
Ok(Action::WaitForEvent {
event,
timeout: Duration::from_millis(timeout_ms),
})
}
"invoke-service" => {
let capability = step
.capability
.context("invoke-service requires 'capability'")?;
let kind = match step.kind.as_deref() {
Some("query" | "q") => ServiceKind::Query,
Some("command" | "cmd") | None => ServiceKind::Command,
Some(other) => bail!("invalid service kind: {other}"),
};
let interface_id = step
.interface
.context("invoke-service requires 'interface'")?;
let operation = step
.operation
.context("invoke-service requires 'operation'")?;
let payload = step.payload.unwrap_or_default();
Ok(Action::InvokeService {
capability,
kind,
interface_id,
operation,
payload,
})
}
"screen" => Ok(Action::Screen),
"status" => Ok(Action::Status),
"render-mark" => {
let id = step.id.context("render-mark requires 'id'")?;
Ok(Action::RenderMark { id })
}
"assert-render" => {
let since = step.since.context("assert-render requires 'since'")?;
Ok(Action::AssertRender {
since,
assertion: RenderAssertion {
min_frames: step.min_frames,
max_frames: step.max_frames,
full_frame: step.full_frame,
max_full_frame_frames: step.max_full_frame_frames,
max_full_surface_fallbacks: step.max_full_surface_fallbacks,
max_damage_rects: step.max_damage_rects,
max_damage_area_cells: step.max_damage_area_cells,
max_rows_emitted: step.max_rows_emitted,
max_row_segments_emitted: step.max_row_segments_emitted,
max_cells_emitted: step.max_cells_emitted,
max_frame_bytes: step.max_frame_bytes,
status_rendered: step.status_rendered,
overlay_rendered: step.overlay_rendered,
expected_emitted_rows: step
.expected_emitted_rows
.as_deref()
.map(parse_render_row_refs_toml)
.transpose()?,
expected_emitted_row_segments: step
.expected_emitted_row_segments
.as_deref()
.map(parse_render_row_segment_refs_toml)
.transpose()?,
expected_trace_ops: step
.expected_trace_ops
.as_deref()
.map(parse_render_trace_ops_toml)
.transpose()?,
},
})
}
other => bail!("unknown action: {other}"),
}
}
fn parse_render_row_refs_toml(rows: &[String]) -> Result<Vec<PlaybookRenderRowRef>> {
rows.iter()
.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_toml(
segments: &[String],
) -> Result<Vec<PlaybookRenderRowSegmentRef>> {
segments
.iter()
.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_toml(ops: &[String]) -> Result<Vec<PlaybookRenderTraceOp>> {
ops.iter()
.map(|entry| PlaybookRenderTraceOp::parse_compact(entry).map_err(anyhow::Error::msg))
.collect()
}
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()
}
#[derive(Deserialize)]
struct RawPlaybook {
playbook: Option<RawPlaybookConfig>,
step: Option<Vec<RawStep>>,
}
#[derive(Deserialize)]
struct RawPlaybookConfig {
name: Option<String>,
description: Option<String>,
viewport: Option<RawViewport>,
shell: Option<String>,
timeout_ms: Option<u64>,
record: Option<bool>,
render_trace: Option<bool>,
plugins: Option<RawPluginConfig>,
vars: Option<BTreeMap<String, String>>,
env: Option<BTreeMap<String, String>>,
env_mode: Option<String>,
include: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct RawViewport {
cols: Option<u16>,
rows: Option<u16>,
}
#[derive(Deserialize)]
struct RawPluginConfig {
enable: Option<Vec<String>>,
disable: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct RawStep {
action: String,
continue_on_error: Option<bool>,
name: Option<String>,
direction: Option<String>,
ratio: Option<f64>,
target: Option<u32>,
keys: Option<String>,
hex: Option<String>,
key: Option<String>,
pane: Option<u32>,
lines: Option<Vec<String>>,
cursor_row: Option<u16>,
cursor_col: Option<u16>,
split: Option<String>,
pattern: Option<String>,
timeout_ms: Option<u64>,
retry: Option<u32>,
ms: Option<u64>,
id: Option<String>,
contains: Option<String>,
not_contains: Option<String>,
matches: Option<String>,
scrollback: Option<bool>,
pane_count: Option<u32>,
row: Option<u16>,
col: Option<u16>,
cols: Option<u16>,
rows: Option<u16>,
event: Option<String>,
capability: Option<String>,
kind: Option<String>,
interface: Option<String>,
operation: Option<String>,
payload: Option<String>,
since: Option<String>,
min_frames: Option<u64>,
max_frames: Option<u64>,
full_frame: Option<bool>,
max_full_frame_frames: Option<u64>,
max_full_surface_fallbacks: Option<u64>,
max_damage_rects: Option<u64>,
max_damage_area_cells: Option<u64>,
max_rows_emitted: Option<u64>,
max_row_segments_emitted: Option<u64>,
max_cells_emitted: Option<u64>,
max_frame_bytes: Option<u64>,
status_rendered: Option<bool>,
overlay_rendered: Option<bool>,
expected_emitted_rows: Option<Vec<String>>,
expected_emitted_row_segments: Option<Vec<String>>,
expected_trace_ops: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_toml() {
let input = r#"
[[step]]
action = "new-session"
name = "main"
[[step]]
action = "send-keys"
keys = "echo hello\r"
"#;
let (playbook, _includes) = parse_toml(input).unwrap();
assert_eq!(playbook.steps.len(), 2);
assert_eq!(playbook.steps[0].action.name(), "new-session");
assert_eq!(playbook.steps[1].action.name(), "send-keys");
}
#[test]
fn parse_full_toml() {
let input = r#"
[playbook]
name = "test"
description = "A test playbook"
viewport = { cols = 120, rows = 50 }
shell = "/bin/bash"
timeout_ms = 15000
record = true
[playbook.plugins]
enable = ["bmux.windows"]
[[step]]
action = "new-session"
name = "main"
[[step]]
action = "split-pane"
direction = "vertical"
ratio = 0.5
[[step]]
action = "send-keys"
keys = "echo hello\\r"
[[step]]
action = "wait-for"
pattern = "hello"
timeout_ms = 3000
[[step]]
action = "assert-screen"
pane = 0
contains = "hello"
"#;
let (playbook, _includes) = parse_toml(input).unwrap();
assert_eq!(playbook.config.name.as_deref(), Some("test"));
assert_eq!(playbook.config.viewport.cols, 120);
assert_eq!(playbook.config.viewport.rows, 50);
assert_eq!(playbook.config.shell.as_deref(), Some("/bin/bash"));
assert_eq!(playbook.config.timeout, Duration::from_secs(15));
assert!(playbook.config.record);
assert_eq!(playbook.config.plugins.enable, vec!["bmux.windows"]);
assert_eq!(playbook.steps.len(), 5);
}
#[test]
fn parse_assert_render_trace_ops() {
let input = r#"
[playbook]
render_trace = true
[[step]]
action = "render-mark"
id = "baseline"
[[step]]
action = "assert-render"
since = "baseline"
expected_trace_ops = ["full-frame", "pane-row-segment:1:2:3:4", "prompt-overlay", "cursor:1:true"]
"#;
let (playbook, _includes) = parse_toml(input).unwrap();
match &playbook.steps[1].action {
Action::AssertRender { assertion, .. } => assert_eq!(
assertion.expected_trace_ops,
Some(vec![
PlaybookRenderTraceOp::FullFrame,
PlaybookRenderTraceOp::PaneRowSegment {
pane: 1,
row: 2,
start_col: 3,
cells: 4,
},
PlaybookRenderTraceOp::PromptOverlay,
PlaybookRenderTraceOp::Cursor {
pane: 1,
visible: true,
},
])
),
other => panic!("expected assert-render, got {other:?}"),
}
}
#[test]
fn parse_toml_defaults() {
let input = r#"
[[step]]
action = "sleep"
ms = 100
"#;
let (playbook, _includes) = parse_toml(input).unwrap();
assert!(playbook.config.name.is_none());
assert_eq!(playbook.config.viewport.cols, 80);
assert_eq!(playbook.config.viewport.rows, 24);
assert_eq!(playbook.config.timeout, Duration::from_secs(30));
assert!(!playbook.config.record);
}
#[test]
fn toml_unknown_action_fails() {
let input = r#"
[[step]]
action = "nonexistent"
"#;
assert!(parse_toml(input).is_err());
}
#[test]
fn toml_split_pane_defaults() {
let input = r#"
[[step]]
action = "split-pane"
"#;
let (playbook, _includes) = parse_toml(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 toml_empty_playbook() {
let input = "";
let (playbook, _includes) = parse_toml(input).unwrap();
assert!(playbook.steps.is_empty());
}
#[test]
fn parse_toml_env_mode_clean() {
let input = r#"
[playbook]
env_mode = "clean"
[[step]]
action = "new-session"
"#;
let (playbook, _) = parse_toml(input).unwrap();
assert_eq!(
playbook.config.env_mode,
Some(super::super::types::SandboxEnvMode::Clean)
);
}
#[test]
fn parse_toml_env_mode_absent() {
let input = r#"
[[step]]
action = "new-session"
"#;
let (playbook, _) = parse_toml(input).unwrap();
assert!(playbook.config.env_mode.is_none());
}
#[test]
fn parse_toml_env_mode_invalid_fails() {
let input = r#"
[playbook]
env_mode = "garbage"
[[step]]
action = "new-session"
"#;
assert!(parse_toml(input).is_err());
}
}