pub mod diff;
pub mod display_track;
pub mod engine;
pub mod from_recording;
pub mod interactive;
pub mod parse_dsl;
pub mod parse_toml;
pub mod sandbox;
pub mod screen;
pub mod subst;
pub mod types;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use self::engine::run_playbook;
use self::types::{Playbook, PlaybookResult, Step};
#[derive(Debug, Clone, Copy, Default)]
pub struct RunOptions {
pub interactive: bool,
}
const MAX_INCLUDE_DEPTH: usize = 10;
pub fn parse_file(path: &Path) -> Result<Playbook> {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut seen = BTreeSet::new();
seen.insert(canonical.clone());
parse_file_recursive(&canonical, &mut seen, 0)
}
pub fn parse_stdin() -> Result<Playbook> {
let mut content = String::new();
std::io::stdin()
.read_to_string(&mut content)
.context("failed reading stdin")?;
let (mut playbook, includes) = parse_content(&content)?;
let base_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut seen = BTreeSet::new();
resolve_includes(&mut playbook, &includes, &base_dir, &mut seen, 0)?;
Ok(playbook)
}
pub async fn run(playbook: Playbook, target_server: bool) -> Result<PlaybookResult> {
run_with_options(playbook, target_server, RunOptions::default()).await
}
pub async fn run_with_options(
playbook: Playbook,
target_server: bool,
options: RunOptions,
) -> Result<PlaybookResult> {
run_playbook(playbook, target_server, options).await
}
#[must_use]
pub fn validate(playbook: &Playbook, target_server: bool) -> Vec<String> {
let mut errors = Vec::new();
if playbook.steps.is_empty() {
errors.push("playbook has no steps".to_string());
}
if !target_server && !matches!(playbook.config.driver, types::PlaybookDriver::AttachSim) {
let first_action = playbook.steps.first().map(|s| &s.action);
if let Some(action) = first_action
&& !matches!(action, types::Action::NewSession { .. })
{
errors.push(
"first step should be 'new-session' (no session exists at start)".to_string(),
);
}
}
if playbook.config.viewport.cols < 10 || playbook.config.viewport.rows < 5 {
errors.push("viewport too small (minimum 10x5)".to_string());
}
let mut expected_pane_count: u32 = 0;
let mut render_marks = BTreeSet::new();
for step in &playbook.steps {
match &step.action {
types::Action::NewSession { .. } => {
expected_pane_count = 1;
}
types::Action::SplitPane { .. } => {
expected_pane_count += 1;
}
types::Action::ClosePane { .. } => {
expected_pane_count = expected_pane_count.saturating_sub(1);
}
types::Action::RenderMark { id } => {
render_marks.insert(id.clone());
}
types::Action::AssertRender { since, assertion } => {
validate_render_assertion_step(
&mut errors,
step.index,
playbook.config.render_trace,
&render_marks,
since,
assertion,
);
}
types::Action::WaitFor {
pattern, timeout, ..
} => {
if timeout.is_zero() {
errors.push(format!("step {}: wait-for has zero timeout", step.index));
}
if let Err(e) = regex::Regex::new(pattern) {
errors.push(format!(
"step {}: invalid regex pattern '{}': {}",
step.index, pattern, e
));
}
}
types::Action::AssertScreen {
matches: Some(pattern),
..
} => {
if let Err(e) = regex::Regex::new(pattern) {
errors.push(format!(
"step {}: invalid regex pattern '{}': {}",
step.index, pattern, e
));
}
}
types::Action::WaitForEvent { event, timeout } => {
validate_wait_for_event_step(&mut errors, step.index, event, *timeout);
}
types::Action::SendKeys {
pane: Some(idx), ..
}
| types::Action::FocusPane { target: idx }
if expected_pane_count < 2 && *idx > 1 =>
{
errors.push(format!(
"step {}: targets pane {} but only {} pane(s) expected at this point",
step.index, idx, expected_pane_count
));
}
_ => {}
}
}
errors
}
fn validate_wait_for_event_step(
errors: &mut Vec<String>,
step_index: usize,
event: &str,
timeout: std::time::Duration,
) {
if timeout.is_zero() {
errors.push(format!(
"step {step_index}: wait-for-event has zero timeout"
));
}
let valid_events = [
"server_started",
"server_stopping",
"session_created",
"session_removed",
"client_attached",
"client_detached",
"attach_view_changed",
];
if !valid_events.contains(&event) {
errors.push(format!(
"step {step_index}: unknown event '{event}'; valid events: {}",
valid_events.join(", ")
));
}
}
fn validate_render_assertion_step(
errors: &mut Vec<String>,
step_index: usize,
render_trace: bool,
render_marks: &BTreeSet<String>,
since: &str,
assertion: &types::RenderAssertion,
) {
if !render_trace {
errors.push(format!(
"step {step_index}: assert-render requires @render-trace true"
));
}
if !render_marks.contains(since) {
errors.push(format!(
"step {step_index}: assert-render references unknown mark '{since}'"
));
}
if let (Some(min), Some(max)) = (assertion.min_frames, assertion.max_frames)
&& min > max
{
errors.push(format!(
"step {step_index}: assert-render min_frames exceeds max_frames"
));
}
}
#[must_use]
pub fn format_result(result: &PlaybookResult) -> String {
let mut out = String::new();
let name = result.playbook_name.as_deref().unwrap_or("<unnamed>");
let status = if result.pass { "PASS" } else { "FAIL" };
let _ = writeln!(
out,
"playbook: {name} — {status} ({} ms)",
result.total_elapsed_ms
);
out.push('\n');
for step in &result.steps {
let icon = match (step.status, step.continue_on_error) {
(types::StepStatus::Pass, _) => "+",
(types::StepStatus::Fail, true) => "~", (types::StepStatus::Fail, false) => "x",
(types::StepStatus::Skip, _) => "-",
};
write!(
out,
" [{icon}] step {}: {} ({} ms)",
step.index, step.action, step.elapsed_ms
)
.unwrap();
if let Some(detail) = &step.detail {
write!(out, " — {detail}").unwrap();
}
out.push('\n');
}
if !result.snapshots.is_empty() {
let _ = writeln!(out, "\nsnapshots: {}", result.snapshots.len());
for snap in &result.snapshots {
let _ = writeln!(out, " - {} ({} panes)", snap.id, snap.panes.len());
}
}
if let Some(rid) = &result.recording_id {
let _ = writeln!(out, "\nrecording: {rid}");
}
if let Some(err) = &result.error {
let _ = writeln!(out, "\nerror: {err}");
}
out
}
fn parse_file_recursive(
path: &Path,
seen: &mut BTreeSet<PathBuf>,
depth: usize,
) -> Result<Playbook> {
if depth > MAX_INCLUDE_DEPTH {
bail!("include depth exceeds maximum ({MAX_INCLUDE_DEPTH})");
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed reading {}", path.display()))?;
let (mut playbook, includes) =
parse_content(&content).with_context(|| format!("failed parsing {}", path.display()))?;
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
resolve_includes(&mut playbook, &includes, &base_dir, seen, depth)?;
Ok(playbook)
}
#[allow(clippy::similar_names)]
fn resolve_includes(
playbook: &mut Playbook,
includes: &[(usize, String)],
base_dir: &Path,
seen: &mut BTreeSet<PathBuf>,
depth: usize,
) -> Result<()> {
if includes.is_empty() {
return Ok(());
}
let mut insertions: Vec<(usize, Vec<Step>)> = Vec::new();
for (insert_at, include_path) in includes {
let resolved = base_dir.join(include_path);
let canonical = resolved
.canonicalize()
.with_context(|| format!("include path not found: {}", resolved.display()))?;
if !seen.insert(canonical.clone()) {
bail!(
"circular include detected: {} already included",
canonical.display()
);
}
let included = parse_file_recursive(&canonical, seen, depth + 1)
.with_context(|| format!("failed parsing included file {}", canonical.display()))?;
merge_included_config(&mut playbook.config, &included.config);
insertions.push((*insert_at, included.steps));
}
insertions.sort_by_key(|insertion| std::cmp::Reverse(insertion.0)); for (insert_at, steps) in insertions {
let pos = insert_at.min(playbook.steps.len());
for (i, step) in steps.into_iter().enumerate() {
playbook.steps.insert(pos + i, step);
}
}
for (i, step) in playbook.steps.iter_mut().enumerate() {
step.index = i;
}
Ok(())
}
fn merge_included_config(parent: &mut types::PlaybookConfig, included: &types::PlaybookConfig) {
if parent.shell.is_none() && included.shell.is_some() {
parent.shell.clone_from(&included.shell);
}
if parent.env_mode.is_none() && included.env_mode.is_some() {
parent.env_mode = included.env_mode;
}
for (key, value) in &included.env {
parent
.env
.entry(key.clone())
.or_insert_with(|| value.clone());
}
for (key, value) in &included.vars {
parent
.vars
.entry(key.clone())
.or_insert_with(|| value.clone());
}
for id in &included.plugins.enable {
if !parent.plugins.enable.contains(id) {
parent.plugins.enable.push(id.clone());
}
}
for id in &included.plugins.disable {
if !parent.plugins.disable.contains(id) {
parent.plugins.disable.push(id.clone());
}
}
}
fn parse_content(content: &str) -> Result<(Playbook, Vec<(usize, String)>)> {
if let Ok(result) = parse_toml::parse_toml(content) {
return Ok(result);
}
parse_dsl::parse_dsl(content)
}
#[cfg(test)]
mod tests {
use super::*;
use types::{PlaybookConfig, PluginConfig, SandboxEnvMode};
fn default_config() -> PlaybookConfig {
PlaybookConfig::default()
}
#[test]
fn include_fills_shell_when_parent_unset() {
let mut parent = default_config();
let included = PlaybookConfig {
shell: Some("sh".to_string()),
..default_config()
};
merge_included_config(&mut parent, &included);
assert_eq!(parent.shell.as_deref(), Some("sh"));
}
#[test]
fn parent_shell_wins_over_included() {
let mut parent = PlaybookConfig {
shell: Some("bash".to_string()),
..default_config()
};
let included = PlaybookConfig {
shell: Some("sh".to_string()),
..default_config()
};
merge_included_config(&mut parent, &included);
assert_eq!(parent.shell.as_deref(), Some("bash"));
}
#[test]
fn first_include_wins_for_shell() {
let mut parent = default_config();
let first = PlaybookConfig {
shell: Some("sh".to_string()),
..default_config()
};
let second = PlaybookConfig {
shell: Some("bash".to_string()),
..default_config()
};
merge_included_config(&mut parent, &first);
merge_included_config(&mut parent, &second);
assert_eq!(parent.shell.as_deref(), Some("sh"));
}
#[test]
fn include_fills_env_mode_when_parent_unset() {
let mut parent = default_config();
let included = PlaybookConfig {
env_mode: Some(SandboxEnvMode::Clean),
..default_config()
};
merge_included_config(&mut parent, &included);
assert_eq!(parent.env_mode, Some(SandboxEnvMode::Clean));
}
#[test]
fn parent_env_mode_wins_over_included() {
let mut parent = PlaybookConfig {
env_mode: Some(SandboxEnvMode::Inherit),
..default_config()
};
let included = PlaybookConfig {
env_mode: Some(SandboxEnvMode::Clean),
..default_config()
};
merge_included_config(&mut parent, &included);
assert_eq!(parent.env_mode, Some(SandboxEnvMode::Inherit));
}
#[test]
fn include_fills_env_gaps() {
let mut parent = default_config();
parent.env.insert("A".into(), "from_parent".into());
let mut included = default_config();
included.env.insert("A".into(), "from_include".into());
included.env.insert("B".into(), "from_include".into());
merge_included_config(&mut parent, &included);
assert_eq!(parent.env["A"], "from_parent", "parent value preserved");
assert_eq!(parent.env["B"], "from_include", "gap filled from include");
}
#[test]
fn include_fills_vars_gaps() {
let mut parent = default_config();
parent.vars.insert("X".into(), "parent_x".into());
let mut included = default_config();
included.vars.insert("X".into(), "include_x".into());
included.vars.insert("Y".into(), "include_y".into());
merge_included_config(&mut parent, &included);
assert_eq!(parent.vars["X"], "parent_x", "parent var preserved");
assert_eq!(parent.vars["Y"], "include_y", "gap filled from include");
}
#[test]
fn include_appends_plugin_enable() {
let mut parent = PlaybookConfig {
plugins: PluginConfig {
enable: vec!["a".into()],
disable: vec![],
},
..default_config()
};
let included = PlaybookConfig {
plugins: PluginConfig {
enable: vec!["a".into(), "b".into()],
disable: vec!["c".into()],
},
..default_config()
};
merge_included_config(&mut parent, &included);
assert_eq!(parent.plugins.enable, vec!["a", "b"], "dedup + append");
assert_eq!(parent.plugins.disable, vec!["c"], "disable appended");
}
#[test]
fn include_does_not_merge_identity_fields() {
let mut parent = default_config();
let included = PlaybookConfig {
name: Some("included-name".into()),
description: Some("included-desc".into()),
..default_config()
};
merge_included_config(&mut parent, &included);
assert!(parent.name.is_none(), "name should not be merged");
assert!(
parent.description.is_none(),
"description should not be merged"
);
}
#[test]
fn parse_file_merges_shell_from_include() {
let fixtures =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/playbooks");
let playbook =
parse_file(&fixtures.join("include_main.dsl")).expect("should parse include_main.dsl");
assert_eq!(
playbook.config.shell.as_deref(),
Some("sh"),
"shell should be set (from main or merged from include)"
);
assert!(
playbook.steps.len() >= 4,
"should have steps from both files"
);
}
}