use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defworkflow")]
pub struct WorkflowSpec {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub steps: Vec<String>,
#[serde(default)]
pub on_failure: String,
#[serde(default)]
pub keybind: String,
#[serde(default)]
pub timeout_ms: u64,
}
pub const KNOWN_FAILURE_MODES: &[&str] = &["abort", "continue", "prompt"];
pub const KNOWN_STEP_KINDS: &[&str] =
&["gate", "action", "workflow", "shell", "cmd", "mcp"];
#[must_use]
pub fn is_known_failure_mode(name: &str) -> bool {
name.is_empty() || KNOWN_FAILURE_MODES.iter().any(|m| *m == name)
}
#[must_use]
pub fn is_known_step_kind(name: &str) -> bool {
KNOWN_STEP_KINDS.iter().any(|k| *k == name)
}
impl WorkflowSpec {
#[must_use]
pub fn step_kinds(&self) -> Vec<&str> {
self.steps
.iter()
.map(|s| s.split(':').next().unwrap_or("?"))
.collect()
}
#[must_use]
pub fn all_steps_known(&self) -> bool {
self.step_kinds().iter().all(|k| is_known_step_kind(k))
}
#[must_use]
pub fn mcp_step_targets(&self) -> Vec<&str> {
self.steps
.iter()
.filter_map(|s| s.strip_prefix("mcp:"))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn step_kinds_extracts_prefixes() {
let w = WorkflowSpec {
name: "x".into(),
steps: vec![
"gate:rust-format-drift".into(),
"action:git.push".into(),
"shell:cargo test".into(),
"cmd:write-all".into(),
"workflow:ship".into(),
"mcp:mado.attention.set".into(),
],
..Default::default()
};
assert_eq!(
w.step_kinds(),
vec!["gate", "action", "shell", "cmd", "workflow", "mcp"]
);
assert!(w.all_steps_known());
}
#[test]
fn mcp_step_targets_extracts_server_dot_tool() {
let w = WorkflowSpec {
name: "x".into(),
steps: vec![
"gate:pre-push".into(),
"mcp:mado.clipboard.put".into(),
"shell:cargo test".into(),
"mcp:mado.attention.set".into(),
"action:git.push".into(),
],
..Default::default()
};
assert_eq!(
w.mcp_step_targets(),
vec!["mado.clipboard.put", "mado.attention.set"],
);
}
#[test]
fn mcp_step_targets_empty_when_no_mcp_steps() {
let w = WorkflowSpec {
name: "x".into(),
steps: vec!["gate:g".into(), "action:a".into()],
..Default::default()
};
assert!(w.mcp_step_targets().is_empty());
}
#[test]
fn malformed_steps_surface_as_question_mark() {
let w = WorkflowSpec {
name: "x".into(),
steps: vec!["just-a-word".into(), "gate:ok".into()],
..Default::default()
};
assert_eq!(w.step_kinds(), vec!["just-a-word", "gate"]);
assert!(!w.all_steps_known());
}
#[test]
fn known_failure_mode_accepts_empty_default() {
assert!(is_known_failure_mode(""));
assert!(is_known_failure_mode("abort"));
assert!(is_known_failure_mode("continue"));
assert!(is_known_failure_mode("prompt"));
assert!(!is_known_failure_mode("explode"));
}
}
impl Default for WorkflowSpec {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
steps: Vec::new(),
on_failure: String::new(),
keybind: String::new(),
timeout_ms: 0,
}
}
}