use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionNode {
Action {
tool: String,
params: HashMap<String, ParamExpr>,
},
Sequence(Vec<ActionNode>),
If {
condition: ConditionExpr,
then: Box<ActionNode>,
#[serde(rename = "else")]
else_: Option<Box<ActionNode>>,
},
ForEach {
variable: String,
collection: CollectionExpr,
body: Box<ActionNode>,
},
StoreResult {
key: String,
action: Box<ActionNode>,
},
}
impl ActionNode {
pub fn action_count(&self) -> usize {
match self {
Self::Action { .. } => 1,
Self::Sequence(nodes) => nodes.iter().map(|n| n.action_count()).sum(),
Self::If { then, else_, .. } => {
then.action_count() + else_.as_ref().map(|e| e.action_count()).unwrap_or(0)
}
Self::ForEach { body, .. } => body.action_count(),
Self::StoreResult { action, .. } => action.action_count(),
}
}
pub fn tool_names(&self) -> Vec<&str> {
let mut names = Vec::new();
self.collect_tool_names(&mut names);
names
}
fn collect_tool_names<'a>(&'a self, names: &mut Vec<&'a str>) {
match self {
Self::Action { tool, .. } => names.push(tool),
Self::Sequence(nodes) => {
for node in nodes {
node.collect_tool_names(names);
}
}
Self::If { then, else_, .. } => {
then.collect_tool_names(names);
if let Some(e) = else_ {
e.collect_tool_names(names);
}
}
Self::ForEach { body, .. } => body.collect_tool_names(names),
Self::StoreResult { action, .. } => action.collect_tool_names(names),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParamExpr {
Literal(serde_json::Value),
Variable(String),
PreviousResult(String),
Computed(ComputeRule),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConditionExpr {
Equals {
left: String,
right: serde_json::Value,
},
Exists(String),
Success(String),
And(Vec<ConditionExpr>),
Or(Vec<ConditionExpr>),
Not(Box<ConditionExpr>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollectionExpr {
Literal(Vec<serde_json::Value>),
FromResult(String),
FromVariable(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputeRule {
Concat(Vec<ParamExpr>),
Format {
template: String,
args: Vec<ParamExpr>,
},
Extract { source: String, field: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_node() {
let node = ActionNode::Action {
tool: "git_commit".into(),
params: HashMap::from([("message".into(), ParamExpr::Variable("commit_msg".into()))]),
};
assert_eq!(node.action_count(), 1);
assert_eq!(node.tool_names(), vec!["git_commit"]);
}
#[test]
fn test_sequence() {
let node = ActionNode::Sequence(vec![
ActionNode::Action {
tool: "git_add".into(),
params: HashMap::from([(
"path".into(),
ParamExpr::Literal(serde_json::json!(".")),
)]),
},
ActionNode::Action {
tool: "git_commit".into(),
params: HashMap::from([("message".into(), ParamExpr::Variable("msg".into()))]),
},
ActionNode::Action {
tool: "git_push".into(),
params: HashMap::new(),
},
]);
assert_eq!(node.action_count(), 3);
assert_eq!(node.tool_names(), vec!["git_add", "git_commit", "git_push"]);
}
#[test]
fn test_if_condition() {
let node = ActionNode::If {
condition: ConditionExpr::Success("step_1".into()),
then: Box::new(ActionNode::Action {
tool: "deploy".into(),
params: HashMap::new(),
}),
else_: Some(Box::new(ActionNode::Action {
tool: "rollback".into(),
params: HashMap::new(),
})),
};
assert_eq!(node.action_count(), 2);
assert_eq!(node.tool_names(), vec!["deploy", "rollback"]);
}
#[test]
fn test_foreach() {
let node = ActionNode::ForEach {
variable: "file".into(),
collection: CollectionExpr::Literal(vec![
serde_json::json!("a.rs"),
serde_json::json!("b.rs"),
]),
body: Box::new(ActionNode::Action {
tool: "lint".into(),
params: HashMap::from([("path".into(), ParamExpr::Variable("file".into()))]),
}),
};
assert_eq!(node.action_count(), 1); assert_eq!(node.tool_names(), vec!["lint"]);
}
#[test]
fn test_store_result() {
let node = ActionNode::StoreResult {
key: "branch".into(),
action: Box::new(ActionNode::Action {
tool: "git_branch".into(),
params: HashMap::new(),
}),
};
assert_eq!(node.action_count(), 1);
}
#[test]
fn test_serialization_roundtrip() {
let node = ActionNode::Sequence(vec![ActionNode::Action {
tool: "test".into(),
params: HashMap::from([
("a".into(), ParamExpr::Literal(serde_json::json!(42))),
("b".into(), ParamExpr::Variable("input".into())),
]),
}]);
let json = serde_json::to_string(&node).unwrap();
let restored: ActionNode = serde_json::from_str(&json).unwrap();
assert_eq!(restored.action_count(), 1);
}
#[test]
fn test_empty_sequence() {
let node = ActionNode::Sequence(vec![]);
assert_eq!(node.action_count(), 0);
assert!(node.tool_names().is_empty());
}
#[test]
fn test_if_without_else() {
let node = ActionNode::If {
condition: ConditionExpr::Exists("x".into()),
then: Box::new(ActionNode::Action { tool: "a".into(), params: HashMap::new() }),
else_: None,
};
assert_eq!(node.action_count(), 1);
assert_eq!(node.tool_names(), vec!["a"]);
}
#[test]
fn test_nested_sequence() {
let node = ActionNode::Sequence(vec![
ActionNode::Sequence(vec![
ActionNode::Action { tool: "a".into(), params: HashMap::new() },
ActionNode::Action { tool: "b".into(), params: HashMap::new() },
]),
ActionNode::Action { tool: "c".into(), params: HashMap::new() },
]);
assert_eq!(node.action_count(), 3);
assert_eq!(node.tool_names(), vec!["a", "b", "c"]);
}
#[test]
fn test_store_result_tool_names() {
let node = ActionNode::StoreResult {
key: "result".into(),
action: Box::new(ActionNode::Sequence(vec![
ActionNode::Action { tool: "x".into(), params: HashMap::new() },
ActionNode::Action { tool: "y".into(), params: HashMap::new() },
])),
};
assert_eq!(node.action_count(), 2);
assert_eq!(node.tool_names(), vec!["x", "y"]);
}
#[test]
fn test_condition_expr_serde() {
let cond = ConditionExpr::And(vec![
ConditionExpr::Exists("x".into()),
ConditionExpr::Not(Box::new(ConditionExpr::Success("y".into()))),
]);
let json = serde_json::to_string(&cond).unwrap();
let _: ConditionExpr = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_collection_expr_serde() {
let c = CollectionExpr::FromResult("step_1".into());
let json = serde_json::to_string(&c).unwrap();
let _: CollectionExpr = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_compute_rule_serde() {
let rule = ComputeRule::Extract { source: "result".into(), field: "id".into() };
let json = serde_json::to_string(&rule).unwrap();
let _: ComputeRule = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_param_expr_previous_result() {
let p = ParamExpr::PreviousResult("step_1".into());
let json = serde_json::to_string(&p).unwrap();
let restored: ParamExpr = serde_json::from_str(&json).unwrap();
assert!(matches!(restored, ParamExpr::PreviousResult(_)));
}
#[test]
fn test_param_expr_computed() {
let p = ParamExpr::Computed(ComputeRule::Concat(vec![
ParamExpr::Literal(serde_json::json!("hello ")),
ParamExpr::Variable("name".into()),
]));
let json = serde_json::to_string(&p).unwrap();
let _: ParamExpr = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_condition_or_serde() {
let cond = ConditionExpr::Or(vec![
ConditionExpr::Exists("a".into()),
ConditionExpr::Exists("b".into()),
]);
let json = serde_json::to_string(&cond).unwrap();
let _: ConditionExpr = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_condition_equals_serde() {
let cond = ConditionExpr::Equals { left: "x".into(), right: serde_json::json!(42) };
let json = serde_json::to_string(&cond).unwrap();
let _: ConditionExpr = serde_json::from_str(&json).unwrap();
}
}