//! 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, #[serde(default)] pub variables: HashMap, pub steps: Vec, } /// A single step in a playbook. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaybookStep { pub command: String, #[serde(default)] pub args: HashMap, /// For assert steps: the condition name. #[serde(default)] pub condition: Option, /// For assert steps: comparison operator. #[serde(default)] pub op: Option, /// For assert steps: expected value. #[serde(default)] pub value: Option, /// Capture the command output into this variable name. #[serde(default)] pub capture: Option, /// Error handling policy for this step. #[serde(default)] pub on_error: OnError, /// Optional loop specification. #[serde(rename = "loop", default)] pub loop_spec: Option, } /// 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, } 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, pub error: Option, } // ── PlaybookRunner ────────────────────────────────────────────────────────── /// Executes a parsed `Playbook` step-by-step. pub struct PlaybookRunner { playbook: Playbook, vars: HashMap, } impl PlaybookRunner { /// Load a playbook from a YAML file. pub fn from_file(path: &Path) -> anyhow::Result { 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 { 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, value: impl Into) { 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 { 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), "mesh-trace" => Ok(Command::MeshTrace { address: self.resolve_str(&step.args, "address")?, }), "mesh-stats" => Ok(Command::MeshStats), other => bail!("unknown command: {other}"), } } /// Build an `AssertCondition` from a playbook step. fn build_assert_condition(&self, step: &PlaybookStep) -> anyhow::Result { 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 { 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, key: &str, ) -> anyhow::Result { 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, key: &str, ) -> Option { 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, 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, key: &str, ) -> anyhow::Result { 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, key: &str, ) -> anyhow::Result { 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, key: &str, ) -> Option { 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")); } }