Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
869 lines
30 KiB
Rust
869 lines
30 KiB
Rust
//! YAML playbook parser and executor.
|
|
//!
|
|
//! Playbooks describe a sequence of client commands in YAML format.
|
|
//! They support variable substitution, assertions, loops, and per-step
|
|
//! error handling policies.
|
|
//!
|
|
//! ```yaml
|
|
//! name: "smoke test"
|
|
//! steps:
|
|
//! - command: dm
|
|
//! args: { username: "bob" }
|
|
//! - command: send
|
|
//! args: { text: "Hello from playbook" }
|
|
//! - command: assert
|
|
//! condition: message_count
|
|
//! op: gte
|
|
//! value: 1
|
|
//! ```
|
|
//!
|
|
//! Requires the `playbook` cargo feature.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, bail};
|
|
use quicprochat_proto::node_capnp::node_service;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::command_engine::{AssertCondition, CmpOp, Command, CommandRegistry};
|
|
use super::session::SessionState;
|
|
|
|
// ── Playbook structs ────────────────────────────────────────────────────────
|
|
|
|
/// A parsed YAML playbook.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Playbook {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
#[serde(default)]
|
|
pub variables: HashMap<String, String>,
|
|
pub steps: Vec<PlaybookStep>,
|
|
}
|
|
|
|
/// A single step in a playbook.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlaybookStep {
|
|
pub command: String,
|
|
#[serde(default)]
|
|
pub args: HashMap<String, serde_yaml::Value>,
|
|
/// For assert steps: the condition name.
|
|
#[serde(default)]
|
|
pub condition: Option<String>,
|
|
/// For assert steps: comparison operator.
|
|
#[serde(default)]
|
|
pub op: Option<String>,
|
|
/// For assert steps: expected value.
|
|
#[serde(default)]
|
|
pub value: Option<serde_yaml::Value>,
|
|
/// Capture the command output into this variable name.
|
|
#[serde(default)]
|
|
pub capture: Option<String>,
|
|
/// Error handling policy for this step.
|
|
#[serde(default)]
|
|
pub on_error: OnError,
|
|
/// Optional loop specification.
|
|
#[serde(rename = "loop", default)]
|
|
pub loop_spec: Option<LoopSpec>,
|
|
}
|
|
|
|
/// What to do when a step fails.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum OnError {
|
|
#[default]
|
|
Fail,
|
|
Skip,
|
|
Continue,
|
|
}
|
|
|
|
/// Loop specification for repeating a step.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LoopSpec {
|
|
pub var: String,
|
|
pub from: usize,
|
|
pub to: usize,
|
|
}
|
|
|
|
// ── Report structs ──────────────────────────────────────────────────────────
|
|
|
|
/// Summary of a playbook execution.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlaybookReport {
|
|
pub name: String,
|
|
pub total_steps: usize,
|
|
pub passed: usize,
|
|
pub failed: usize,
|
|
pub skipped: usize,
|
|
pub duration: Duration,
|
|
pub step_results: Vec<StepResult>,
|
|
}
|
|
|
|
impl PlaybookReport {
|
|
/// True if all steps passed (no failures).
|
|
pub fn all_passed(&self) -> bool {
|
|
self.failed == 0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PlaybookReport {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
writeln!(f, "Playbook: {}", self.name)?;
|
|
writeln!(
|
|
f,
|
|
"Result: {} passed, {} failed, {} skipped ({} total)",
|
|
self.passed, self.failed, self.skipped, self.total_steps,
|
|
)?;
|
|
writeln!(f, "Duration: {:.2}s", self.duration.as_secs_f64())?;
|
|
for sr in &self.step_results {
|
|
let status = if sr.success { "OK" } else { "FAIL" };
|
|
write!(
|
|
f,
|
|
" [{}/{}] {} ... {} ({:.1}ms)",
|
|
sr.step_index + 1,
|
|
self.total_steps,
|
|
sr.command,
|
|
status,
|
|
sr.duration.as_secs_f64() * 1000.0,
|
|
)?;
|
|
if let Some(ref e) = sr.error {
|
|
write!(f, " — {e}")?;
|
|
}
|
|
writeln!(f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Result of a single step execution.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StepResult {
|
|
pub step_index: usize,
|
|
pub command: String,
|
|
pub success: bool,
|
|
pub duration: Duration,
|
|
pub output: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
// ── PlaybookRunner ──────────────────────────────────────────────────────────
|
|
|
|
/// Executes a parsed `Playbook` step-by-step.
|
|
pub struct PlaybookRunner {
|
|
playbook: Playbook,
|
|
vars: HashMap<String, String>,
|
|
}
|
|
|
|
impl PlaybookRunner {
|
|
/// Load a playbook from a YAML file.
|
|
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
|
let content =
|
|
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
|
Self::from_str(&content)
|
|
}
|
|
|
|
/// Parse a playbook from a YAML string.
|
|
pub fn from_str(yaml: &str) -> anyhow::Result<Self> {
|
|
let playbook: Playbook =
|
|
serde_yaml::from_str(yaml).context("parse playbook YAML")?;
|
|
let vars = playbook.variables.clone();
|
|
Ok(Self { playbook, vars })
|
|
}
|
|
|
|
/// Override or add variables before execution.
|
|
pub fn set_var(&mut self, name: impl Into<String>, value: impl Into<String>) {
|
|
self.vars.insert(name.into(), value.into());
|
|
}
|
|
|
|
/// Execute all steps, returning a report.
|
|
pub async fn run(
|
|
&mut self,
|
|
session: &mut SessionState,
|
|
client: &node_service::Client,
|
|
) -> PlaybookReport {
|
|
let start = Instant::now();
|
|
let total = self.expanded_step_count();
|
|
let mut results = Vec::new();
|
|
let mut passed = 0usize;
|
|
let mut failed = 0usize;
|
|
let mut skipped = 0usize;
|
|
let mut step_idx = 0usize;
|
|
let mut abort = false;
|
|
|
|
for step in &self.playbook.steps.clone() {
|
|
if abort {
|
|
skipped += 1;
|
|
results.push(StepResult {
|
|
step_index: step_idx,
|
|
command: step.command.clone(),
|
|
success: false,
|
|
duration: Duration::ZERO,
|
|
output: None,
|
|
error: Some("skipped (prior failure)".into()),
|
|
});
|
|
step_idx += 1;
|
|
continue;
|
|
}
|
|
|
|
if let Some(ref ls) = step.loop_spec {
|
|
for i in ls.from..=ls.to {
|
|
self.vars.insert(ls.var.clone(), i.to_string());
|
|
let sr = self.execute_step(step, step_idx, total, session, client).await;
|
|
if sr.success {
|
|
passed += 1;
|
|
} else {
|
|
failed += 1;
|
|
if step.on_error == OnError::Fail {
|
|
abort = true;
|
|
}
|
|
}
|
|
results.push(sr);
|
|
step_idx += 1;
|
|
if abort {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
let sr = self.execute_step(step, step_idx, total, session, client).await;
|
|
if sr.success {
|
|
passed += 1;
|
|
} else {
|
|
match step.on_error {
|
|
OnError::Fail => {
|
|
failed += 1;
|
|
abort = true;
|
|
}
|
|
OnError::Skip => skipped += 1,
|
|
OnError::Continue => failed += 1,
|
|
}
|
|
}
|
|
results.push(sr);
|
|
step_idx += 1;
|
|
}
|
|
}
|
|
|
|
PlaybookReport {
|
|
name: self.playbook.name.clone(),
|
|
total_steps: step_idx,
|
|
passed,
|
|
failed,
|
|
skipped,
|
|
duration: start.elapsed(),
|
|
step_results: results,
|
|
}
|
|
}
|
|
|
|
/// Execute a single step.
|
|
async fn execute_step(
|
|
&mut self,
|
|
step: &PlaybookStep,
|
|
index: usize,
|
|
total: usize,
|
|
session: &mut SessionState,
|
|
client: &node_service::Client,
|
|
) -> StepResult {
|
|
let t = Instant::now();
|
|
let cmd = match self.step_to_command(step) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return StepResult {
|
|
step_index: index,
|
|
command: step.command.clone(),
|
|
success: false,
|
|
duration: t.elapsed(),
|
|
output: None,
|
|
error: Some(format!("{e:#}")),
|
|
};
|
|
}
|
|
};
|
|
|
|
eprintln!(
|
|
"[{}/{}] {} ...",
|
|
index + 1,
|
|
total,
|
|
step.command,
|
|
);
|
|
|
|
let cr = CommandRegistry::execute(&cmd, session, client).await;
|
|
|
|
// Capture output into variable if requested.
|
|
if let Some(ref var_name) = step.capture {
|
|
if let Some(ref out) = cr.output {
|
|
self.vars.insert(var_name.clone(), out.clone());
|
|
}
|
|
for (k, v) in &cr.data {
|
|
self.vars.insert(format!("{var_name}.{k}"), v.clone());
|
|
}
|
|
}
|
|
|
|
StepResult {
|
|
step_index: index,
|
|
command: step.command.clone(),
|
|
success: cr.success,
|
|
duration: t.elapsed(),
|
|
output: cr.output,
|
|
error: cr.error,
|
|
}
|
|
}
|
|
|
|
/// Convert a YAML step into a typed `Command`.
|
|
fn step_to_command(&self, step: &PlaybookStep) -> anyhow::Result<Command> {
|
|
let cmd_name = step.command.as_str();
|
|
match cmd_name {
|
|
// ── Lifecycle commands ────────────────────────────────────────
|
|
"connect" => Ok(Command::Connect {
|
|
server: self.resolve_str(&step.args, "server")?,
|
|
ca_cert: self.opt_str(&step.args, "ca_cert"),
|
|
insecure: self.opt_bool(&step.args, "insecure"),
|
|
}),
|
|
"login" => Ok(Command::Login {
|
|
username: self.resolve_str(&step.args, "username")?,
|
|
password: self.resolve_str(&step.args, "password")?,
|
|
}),
|
|
"register" => Ok(Command::Register {
|
|
username: self.resolve_str(&step.args, "username")?,
|
|
password: self.resolve_str(&step.args, "password")?,
|
|
}),
|
|
"send" | "send-message" => Ok(Command::SendMessage {
|
|
text: self.resolve_str(&step.args, "text")?,
|
|
}),
|
|
"wait" => Ok(Command::Wait {
|
|
duration_ms: self.resolve_u64(&step.args, "duration_ms")?,
|
|
}),
|
|
"set-var" | "setvar" => Ok(Command::SetVar {
|
|
name: self.resolve_str(&step.args, "name")?,
|
|
value: self.resolve_str(&step.args, "value")?,
|
|
}),
|
|
"assert" => {
|
|
let condition = self.build_assert_condition(step)?;
|
|
Ok(Command::Assert { condition })
|
|
}
|
|
|
|
// ── Session / identity ───────────────────────────────────────
|
|
"help" => Ok(Command::Help),
|
|
"quit" | "exit" => Ok(Command::Quit),
|
|
"whoami" => Ok(Command::Whoami),
|
|
"list" | "ls" => Ok(Command::List),
|
|
"switch" | "sw" => Ok(Command::Switch {
|
|
target: self.resolve_str(&step.args, "target")?,
|
|
}),
|
|
"dm" => Ok(Command::Dm {
|
|
username: self.resolve_str(&step.args, "username")?,
|
|
}),
|
|
"create-group" | "cg" => Ok(Command::CreateGroup {
|
|
name: self.resolve_str(&step.args, "name")?,
|
|
}),
|
|
"invite" => Ok(Command::Invite {
|
|
target: self.resolve_str(&step.args, "target")?,
|
|
}),
|
|
"remove" | "kick" => Ok(Command::Remove {
|
|
target: self.resolve_str(&step.args, "target")?,
|
|
}),
|
|
"leave" => Ok(Command::Leave),
|
|
"join" => Ok(Command::Join),
|
|
"members" => Ok(Command::Members),
|
|
"group-info" | "gi" => Ok(Command::GroupInfo),
|
|
"rename" => Ok(Command::Rename {
|
|
name: self.resolve_str(&step.args, "name")?,
|
|
}),
|
|
"history" | "hist" => Ok(Command::History {
|
|
count: self.opt_usize(&step.args, "count").unwrap_or(20),
|
|
}),
|
|
|
|
// ── Security / crypto ────────────────────────────────────────
|
|
"verify" => Ok(Command::Verify {
|
|
username: self.resolve_str(&step.args, "username")?,
|
|
}),
|
|
"update-key" | "rotate-key" => Ok(Command::UpdateKey),
|
|
"typing" => Ok(Command::Typing),
|
|
"typing-notify" => Ok(Command::TypingNotify {
|
|
enabled: self.opt_bool(&step.args, "enabled"),
|
|
}),
|
|
"react" => Ok(Command::React {
|
|
emoji: self.resolve_str(&step.args, "emoji")?,
|
|
index: self.opt_usize(&step.args, "index"),
|
|
}),
|
|
"edit" => Ok(Command::Edit {
|
|
index: self.resolve_usize(&step.args, "index")?,
|
|
new_text: self.resolve_str(&step.args, "new_text")?,
|
|
}),
|
|
"delete" | "del" => Ok(Command::Delete {
|
|
index: self.resolve_usize(&step.args, "index")?,
|
|
}),
|
|
"send-file" | "sf" => Ok(Command::SendFile {
|
|
path: self.resolve_str(&step.args, "path")?,
|
|
}),
|
|
"download" | "dl" => Ok(Command::Download {
|
|
index: self.resolve_usize(&step.args, "index")?,
|
|
}),
|
|
"delete-account" => Ok(Command::DeleteAccount),
|
|
"disappear" => Ok(Command::Disappear {
|
|
arg: self.opt_str(&step.args, "duration"),
|
|
}),
|
|
"privacy" => Ok(Command::Privacy {
|
|
arg: self.opt_str(&step.args, "setting"),
|
|
}),
|
|
"verify-fs" => Ok(Command::VerifyFs),
|
|
"rotate-all-keys" => Ok(Command::RotateAllKeys),
|
|
"devices" => Ok(Command::Devices),
|
|
"register-device" => Ok(Command::RegisterDevice {
|
|
name: self.resolve_str(&step.args, "name")?,
|
|
}),
|
|
"revoke-device" => Ok(Command::RevokeDevice {
|
|
id_prefix: self.resolve_str(&step.args, "id_prefix")?,
|
|
}),
|
|
|
|
// ── Mesh ─────────────────────────────────────────────────────
|
|
"mesh-peers" => Ok(Command::MeshPeers),
|
|
"mesh-server" => Ok(Command::MeshServer {
|
|
addr: self.resolve_str(&step.args, "addr")?,
|
|
}),
|
|
"mesh-send" => Ok(Command::MeshSend {
|
|
peer_id: self.resolve_str(&step.args, "peer_id")?,
|
|
message: self.resolve_str(&step.args, "message")?,
|
|
}),
|
|
"mesh-broadcast" => Ok(Command::MeshBroadcast {
|
|
topic: self.resolve_str(&step.args, "topic")?,
|
|
message: self.resolve_str(&step.args, "message")?,
|
|
}),
|
|
"mesh-subscribe" => Ok(Command::MeshSubscribe {
|
|
topic: self.resolve_str(&step.args, "topic")?,
|
|
}),
|
|
"mesh-route" => Ok(Command::MeshRoute),
|
|
"mesh-identity" | "mesh-id" => Ok(Command::MeshIdentity),
|
|
"mesh-store" => Ok(Command::MeshStore),
|
|
|
|
other => bail!("unknown command: {other}"),
|
|
}
|
|
}
|
|
|
|
/// Build an `AssertCondition` from a playbook step.
|
|
fn build_assert_condition(&self, step: &PlaybookStep) -> anyhow::Result<AssertCondition> {
|
|
let cond = step
|
|
.condition
|
|
.as_deref()
|
|
.context("assert step requires 'condition' field")?;
|
|
match cond {
|
|
"connected" => Ok(AssertCondition::Connected),
|
|
"logged_in" => Ok(AssertCondition::LoggedIn),
|
|
"in_conversation" => {
|
|
let name = self.resolve_str(&step.args, "name")
|
|
.or_else(|_| step.value.as_ref()
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| self.substitute(s))
|
|
.context("assert in_conversation requires 'name' arg or 'value'"))?;
|
|
Ok(AssertCondition::InConversation { name })
|
|
}
|
|
"message_count" => {
|
|
let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
|
|
let count = step
|
|
.value
|
|
.as_ref()
|
|
.and_then(|v| v.as_u64())
|
|
.context("message_count assert requires numeric 'value'")?
|
|
as usize;
|
|
Ok(AssertCondition::MessageCount { op, count })
|
|
}
|
|
"member_count" => {
|
|
let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
|
|
let count = step
|
|
.value
|
|
.as_ref()
|
|
.and_then(|v| v.as_u64())
|
|
.context("member_count assert requires numeric 'value'")?
|
|
as usize;
|
|
Ok(AssertCondition::MemberCount { op, count })
|
|
}
|
|
other => Ok(AssertCondition::Custom {
|
|
expression: other.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn parse_cmp_op(&self, s: &str) -> anyhow::Result<CmpOp> {
|
|
match s {
|
|
"eq" | "==" => Ok(CmpOp::Eq),
|
|
"ne" | "!=" => Ok(CmpOp::Ne),
|
|
"gt" | ">" => Ok(CmpOp::Gt),
|
|
"lt" | "<" => Ok(CmpOp::Lt),
|
|
"gte" | ">=" => Ok(CmpOp::Gte),
|
|
"lte" | "<=" => Ok(CmpOp::Lte),
|
|
other => bail!("unknown comparison operator: {other}"),
|
|
}
|
|
}
|
|
|
|
// ── Variable substitution helpers ────────────────────────────────────
|
|
|
|
/// Substitute `$varname` and `${VAR:-default}` in a string.
|
|
fn substitute(&self, s: &str) -> String {
|
|
let mut result = String::with_capacity(s.len());
|
|
let mut chars = s.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
if c == '$' {
|
|
if chars.peek() == Some(&'{') {
|
|
chars.next(); // consume '{'
|
|
let mut key = String::new();
|
|
let mut default = None;
|
|
while let Some(&ch) = chars.peek() {
|
|
if ch == '}' {
|
|
chars.next();
|
|
break;
|
|
}
|
|
if ch == ':' && chars.clone().nth(1) == Some('-') {
|
|
chars.next(); // consume ':'
|
|
chars.next(); // consume '-'
|
|
let mut def = String::new();
|
|
while let Some(&dch) = chars.peek() {
|
|
if dch == '}' {
|
|
chars.next();
|
|
break;
|
|
}
|
|
def.push(dch);
|
|
chars.next();
|
|
}
|
|
default = Some(def);
|
|
break;
|
|
}
|
|
key.push(ch);
|
|
chars.next();
|
|
}
|
|
if let Some(val) = self.vars.get(&key) {
|
|
result.push_str(val);
|
|
} else if let Ok(val) = std::env::var(&key) {
|
|
result.push_str(&val);
|
|
} else if let Some(def) = default {
|
|
result.push_str(&def);
|
|
}
|
|
} else {
|
|
let mut key = String::new();
|
|
while let Some(&ch) = chars.peek() {
|
|
if ch.is_alphanumeric() || ch == '_' {
|
|
key.push(ch);
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if let Some(val) = self.vars.get(&key) {
|
|
result.push_str(val);
|
|
} else {
|
|
result.push('$');
|
|
result.push_str(&key);
|
|
}
|
|
}
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Resolve a required string argument with variable substitution.
|
|
fn resolve_str(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> anyhow::Result<String> {
|
|
let val = args
|
|
.get(key)
|
|
.with_context(|| format!("missing required argument: {key}"))?;
|
|
match val {
|
|
serde_yaml::Value::String(s) => Ok(self.substitute(s)),
|
|
serde_yaml::Value::Number(n) => Ok(n.to_string()),
|
|
serde_yaml::Value::Bool(b) => Ok(b.to_string()),
|
|
other => Ok(format!("{other:?}")),
|
|
}
|
|
}
|
|
|
|
/// Resolve an optional string argument.
|
|
fn opt_str(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> Option<String> {
|
|
args.get(key).map(|v| match v {
|
|
serde_yaml::Value::String(s) => self.substitute(s),
|
|
serde_yaml::Value::Number(n) => n.to_string(),
|
|
serde_yaml::Value::Bool(b) => b.to_string(),
|
|
other => format!("{other:?}"),
|
|
})
|
|
}
|
|
|
|
/// Resolve an optional bool argument (defaults to false).
|
|
fn opt_bool(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> bool {
|
|
args.get(key)
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Resolve a required usize argument.
|
|
fn resolve_usize(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> anyhow::Result<usize> {
|
|
let val = args
|
|
.get(key)
|
|
.with_context(|| format!("missing required argument: {key}"))?;
|
|
val.as_u64()
|
|
.map(|n| n as usize)
|
|
.with_context(|| format!("argument '{key}' must be a positive integer"))
|
|
}
|
|
|
|
/// Resolve a required u64 argument.
|
|
fn resolve_u64(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> anyhow::Result<u64> {
|
|
let val = args
|
|
.get(key)
|
|
.with_context(|| format!("missing required argument: {key}"))?;
|
|
val.as_u64()
|
|
.with_context(|| format!("argument '{key}' must be a positive integer"))
|
|
}
|
|
|
|
/// Resolve an optional usize argument.
|
|
fn opt_usize(
|
|
&self,
|
|
args: &HashMap<String, serde_yaml::Value>,
|
|
key: &str,
|
|
) -> Option<usize> {
|
|
args.get(key).and_then(|v| v.as_u64()).map(|n| n as usize)
|
|
}
|
|
|
|
/// Count total expanded steps (including loop iterations).
|
|
fn expanded_step_count(&self) -> usize {
|
|
self.playbook
|
|
.steps
|
|
.iter()
|
|
.map(|s| {
|
|
if let Some(ref ls) = s.loop_spec {
|
|
if ls.to >= ls.from {
|
|
ls.to - ls.from + 1
|
|
} else {
|
|
0
|
|
}
|
|
} else {
|
|
1
|
|
}
|
|
})
|
|
.sum()
|
|
}
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_minimal_playbook() {
|
|
let yaml = r#"
|
|
name: "test"
|
|
steps:
|
|
- command: whoami
|
|
- command: list
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
assert_eq!(runner.playbook.name, "test");
|
|
assert_eq!(runner.playbook.steps.len(), 2);
|
|
assert_eq!(runner.playbook.steps[0].command, "whoami");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_playbook_with_variables() {
|
|
let yaml = r#"
|
|
name: "var test"
|
|
variables:
|
|
user: alice
|
|
server: "127.0.0.1:5001"
|
|
steps:
|
|
- command: dm
|
|
args:
|
|
username: "$user"
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
assert_eq!(runner.vars["user"], "alice");
|
|
assert_eq!(runner.vars["server"], "127.0.0.1:5001");
|
|
}
|
|
|
|
#[test]
|
|
fn variable_substitution() {
|
|
let mut vars = HashMap::new();
|
|
vars.insert("name".to_string(), "alice".to_string());
|
|
vars.insert("port".to_string(), "5001".to_string());
|
|
let runner = PlaybookRunner {
|
|
playbook: Playbook {
|
|
name: "test".into(),
|
|
description: None,
|
|
variables: HashMap::new(),
|
|
steps: vec![],
|
|
},
|
|
vars,
|
|
};
|
|
assert_eq!(runner.substitute("hello $name"), "hello alice");
|
|
assert_eq!(runner.substitute("port=$port!"), "port=5001!");
|
|
assert_eq!(runner.substitute("${name}@server"), "alice@server");
|
|
assert_eq!(
|
|
runner.substitute("${missing:-default}"),
|
|
"default"
|
|
);
|
|
assert_eq!(runner.substitute("no vars here"), "no vars here");
|
|
}
|
|
|
|
#[test]
|
|
fn step_to_command_mapping() {
|
|
let yaml = r#"
|
|
name: "mapping test"
|
|
variables:
|
|
user: bob
|
|
steps:
|
|
- command: dm
|
|
args:
|
|
username: "$user"
|
|
- command: send
|
|
args:
|
|
text: "hello"
|
|
- command: history
|
|
args:
|
|
count: 10
|
|
- command: wait
|
|
args:
|
|
duration_ms: 500
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
let cmd0 = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
|
|
assert!(matches!(cmd0, Command::Dm { username } if username == "bob"));
|
|
|
|
let cmd1 = runner.step_to_command(&runner.playbook.steps[1]).unwrap();
|
|
assert!(matches!(cmd1, Command::SendMessage { text } if text == "hello"));
|
|
|
|
let cmd2 = runner.step_to_command(&runner.playbook.steps[2]).unwrap();
|
|
assert!(matches!(cmd2, Command::History { count: 10 }));
|
|
|
|
let cmd3 = runner.step_to_command(&runner.playbook.steps[3]).unwrap();
|
|
assert!(matches!(cmd3, Command::Wait { duration_ms: 500 }));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_assert_step() {
|
|
let yaml = r#"
|
|
name: "assert test"
|
|
steps:
|
|
- command: assert
|
|
condition: message_count
|
|
op: gte
|
|
value: 5
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
let cmd = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
|
|
match cmd {
|
|
Command::Assert {
|
|
condition: AssertCondition::MessageCount { op, count },
|
|
} => {
|
|
assert_eq!(op, CmpOp::Gte);
|
|
assert_eq!(count, 5);
|
|
}
|
|
other => panic!("expected Assert MessageCount, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_loop_spec() {
|
|
let yaml = r#"
|
|
name: "loop test"
|
|
steps:
|
|
- command: send
|
|
args:
|
|
text: "msg $i"
|
|
loop:
|
|
var: i
|
|
from: 1
|
|
to: 5
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
assert_eq!(runner.expanded_step_count(), 5);
|
|
let ls = runner.playbook.steps[0].loop_spec.as_ref().unwrap();
|
|
assert_eq!(ls.var, "i");
|
|
assert_eq!(ls.from, 1);
|
|
assert_eq!(ls.to, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn on_error_defaults_to_fail() {
|
|
let yaml = r#"
|
|
name: "error test"
|
|
steps:
|
|
- command: whoami
|
|
- command: list
|
|
on_error: continue
|
|
- command: quit
|
|
on_error: skip
|
|
"#;
|
|
let runner = PlaybookRunner::from_str(yaml).unwrap();
|
|
assert_eq!(runner.playbook.steps[0].on_error, OnError::Fail);
|
|
assert_eq!(runner.playbook.steps[1].on_error, OnError::Continue);
|
|
assert_eq!(runner.playbook.steps[2].on_error, OnError::Skip);
|
|
}
|
|
|
|
#[test]
|
|
fn cmp_op_parsing() {
|
|
let runner = PlaybookRunner::from_str("name: t\nsteps: []").unwrap();
|
|
assert!(matches!(runner.parse_cmp_op("eq"), Ok(CmpOp::Eq)));
|
|
assert!(matches!(runner.parse_cmp_op("=="), Ok(CmpOp::Eq)));
|
|
assert!(matches!(runner.parse_cmp_op("gte"), Ok(CmpOp::Gte)));
|
|
assert!(matches!(runner.parse_cmp_op(">="), Ok(CmpOp::Gte)));
|
|
assert!(matches!(runner.parse_cmp_op("<"), Ok(CmpOp::Lt)));
|
|
assert!(runner.parse_cmp_op("invalid").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn report_display() {
|
|
let report = PlaybookReport {
|
|
name: "test".into(),
|
|
total_steps: 3,
|
|
passed: 2,
|
|
failed: 1,
|
|
skipped: 0,
|
|
duration: Duration::from_millis(150),
|
|
step_results: vec![
|
|
StepResult {
|
|
step_index: 0,
|
|
command: "whoami".into(),
|
|
success: true,
|
|
duration: Duration::from_millis(10),
|
|
output: None,
|
|
error: None,
|
|
},
|
|
StepResult {
|
|
step_index: 1,
|
|
command: "dm".into(),
|
|
success: true,
|
|
duration: Duration::from_millis(50),
|
|
output: None,
|
|
error: None,
|
|
},
|
|
StepResult {
|
|
step_index: 2,
|
|
command: "assert".into(),
|
|
success: false,
|
|
duration: Duration::from_millis(1),
|
|
output: None,
|
|
error: Some("message count 0 < 1".into()),
|
|
},
|
|
],
|
|
};
|
|
let s = format!("{report}");
|
|
assert!(s.contains("2 passed, 1 failed"));
|
|
assert!(s.contains("[3/3] assert ... FAIL"));
|
|
}
|
|
}
|