- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用) - GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装) - HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时) - NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义) - 8个模板加载器测试+37个节点测试全绿
331 lines
8.1 KiB
Rust
331 lines
8.1 KiB
Rust
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
|
|
//!
|
|
//! 模板格式:
|
|
//! ```yaml
|
|
//! name: 代码审查
|
|
//! description: AI 驱动的代码审查流程
|
|
//! nodes:
|
|
//! review:
|
|
//! type: ai
|
|
//! config:
|
|
//! prompt: "审查以下代码变更..."
|
|
//! persona_id: reviewer
|
|
//! notify:
|
|
//! type: notify
|
|
//! config:
|
|
//! type: desktop
|
|
//! title: "审查完成"
|
|
//! edges:
|
|
//! - from: review
|
|
//! to: notify
|
|
//! ```
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
|
|
|
|
/// YAML 模板顶层结构
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkflowTemplate {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
pub nodes: HashMap<String, TemplateNode>,
|
|
#[serde(default)]
|
|
pub edges: Vec<TemplateEdge>,
|
|
}
|
|
|
|
/// 模板节点(YAML 友好命名)
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TemplateNode {
|
|
/// 节点类型(ai/script/human/git/http/notify/subflow)
|
|
#[serde(rename = "type")]
|
|
pub node_type: String,
|
|
#[serde(default)]
|
|
pub config: serde_json::Value,
|
|
#[serde(default)]
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
/// 模板边(YAML 友好命名)
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TemplateEdge {
|
|
pub from: String,
|
|
pub to: String,
|
|
#[serde(default)]
|
|
pub condition: Option<String>,
|
|
}
|
|
|
|
/// 模板加载错误
|
|
#[derive(Debug)]
|
|
pub enum TemplateError {
|
|
/// YAML 解析失败
|
|
YamlParse(String),
|
|
/// 校验失败(空节点/未知类型/依赖环)
|
|
Validation(String),
|
|
}
|
|
|
|
impl std::fmt::Display for TemplateError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
|
|
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TemplateError {}
|
|
|
|
/// 已知的节点类型白名单
|
|
const KNOWN_NODE_TYPES: &[&str] = &[
|
|
"ai", "script", "human", "git", "http", "notify", "subflow",
|
|
];
|
|
|
|
/// 从 YAML 字符串加载模板并转换为 DagDef
|
|
///
|
|
/// 步骤:
|
|
/// 1. YAML → WorkflowTemplate(serde_yaml)
|
|
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
|
|
/// 3. 转换为 DagDef
|
|
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
|
|
// 1. YAML 解析
|
|
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
|
|
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
|
|
|
|
// 2. 校验
|
|
validate_template(&template)?;
|
|
|
|
// 3. 转换为 DagDef
|
|
let mut dag = DagDef::new();
|
|
for (id, node) in &template.nodes {
|
|
dag.nodes.insert(
|
|
id.clone(),
|
|
NodeDef {
|
|
id: id.clone(),
|
|
node_type: node.node_type.clone(),
|
|
config: node.config.clone(),
|
|
label: node.label.clone(),
|
|
},
|
|
);
|
|
}
|
|
for edge in &template.edges {
|
|
dag.edges.push(EdgeDef {
|
|
source: edge.from.clone(),
|
|
target: edge.to.clone(),
|
|
condition: edge.condition.clone(),
|
|
});
|
|
}
|
|
|
|
Ok(dag)
|
|
}
|
|
|
|
/// 校验模板结构
|
|
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
|
|
// 节点非空
|
|
if t.nodes.is_empty() {
|
|
return Err(TemplateError::Validation("模板节点不能为空".into()));
|
|
}
|
|
|
|
// 节点类型合法
|
|
for (id, node) in &t.nodes {
|
|
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
|
|
return Err(TemplateError::Validation(format!(
|
|
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
|
|
id, node.node_type, KNOWN_NODE_TYPES
|
|
)));
|
|
}
|
|
}
|
|
|
|
// 边引用的节点存在
|
|
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
|
|
for edge in &t.edges {
|
|
if !node_ids.contains(&edge.from) {
|
|
return Err(TemplateError::Validation(format!(
|
|
"边的起点 '{}' 不存在", edge.from
|
|
)));
|
|
}
|
|
if !node_ids.contains(&edge.to) {
|
|
return Err(TemplateError::Validation(format!(
|
|
"边的终点 '{}' 不存在", edge.to
|
|
)));
|
|
}
|
|
}
|
|
|
|
// 依赖无环(DFS 检测)
|
|
if has_cycle(&t.edges) {
|
|
return Err(TemplateError::Validation("依赖存在环".into()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// DFS 检测边列表是否有环
|
|
fn has_cycle(edges: &[TemplateEdge]) -> bool {
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
// 建邻接表
|
|
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
|
for e in edges {
|
|
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
|
|
}
|
|
|
|
let mut visited: HashSet<&str> = HashSet::new();
|
|
let mut in_stack: HashSet<&str> = HashSet::new();
|
|
|
|
fn dfs<'a>(
|
|
node: &'a str,
|
|
adj: &HashMap<&'a str, Vec<&'a str>>,
|
|
visited: &mut HashSet<&'a str>,
|
|
in_stack: &mut HashSet<&'a str>,
|
|
) -> bool {
|
|
if in_stack.contains(node) {
|
|
return true; // 环
|
|
}
|
|
if visited.contains(node) {
|
|
return false; // 已访问过,无环
|
|
}
|
|
visited.insert(node);
|
|
in_stack.insert(node);
|
|
if let Some(neighbors) = adj.get(node) {
|
|
for next in neighbors {
|
|
if dfs(next, adj, visited, in_stack) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
in_stack.remove(node);
|
|
false
|
|
}
|
|
|
|
for e in edges {
|
|
let start = e.from.as_str();
|
|
if dfs(start, &adj, &mut visited, &mut in_stack) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tpl_01_valid_template_loads() {
|
|
let yaml = r#"
|
|
name: 代码审查
|
|
description: AI 驱动的代码审查
|
|
nodes:
|
|
review:
|
|
type: ai
|
|
config:
|
|
prompt: "审查代码"
|
|
notify:
|
|
type: notify
|
|
config:
|
|
type: desktop
|
|
title: "完成"
|
|
edges:
|
|
- from: review
|
|
to: notify
|
|
"#;
|
|
let dag = load_template(yaml).unwrap();
|
|
assert_eq!(dag.nodes.len(), 2);
|
|
assert_eq!(dag.edges.len(), 1);
|
|
assert!(dag.nodes.contains_key("review"));
|
|
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_02_empty_nodes_rejected() {
|
|
let yaml = "name: 空\nnodes: {}\n";
|
|
let result = load_template(yaml);
|
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_03_unknown_node_type_rejected() {
|
|
let yaml = r#"
|
|
name: test
|
|
nodes:
|
|
bad:
|
|
type: unknown_type
|
|
config: {}
|
|
"#;
|
|
let result = load_template(yaml);
|
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_04_cycle_detected() {
|
|
let yaml = r#"
|
|
name: 环
|
|
nodes:
|
|
a:
|
|
type: ai
|
|
config: {}
|
|
b:
|
|
type: ai
|
|
config: {}
|
|
edges:
|
|
- from: a
|
|
to: b
|
|
- from: b
|
|
to: a
|
|
"#;
|
|
let result = load_template(yaml);
|
|
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("环")));
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_05_edge_to_nonexistent_rejected() {
|
|
let yaml = r#"
|
|
name: test
|
|
nodes:
|
|
a:
|
|
type: ai
|
|
config: {}
|
|
edges:
|
|
- from: a
|
|
to: ghost
|
|
"#;
|
|
let result = load_template(yaml);
|
|
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_06_no_edges_ok() {
|
|
let yaml = r#"
|
|
name: 单节点
|
|
nodes:
|
|
solo:
|
|
type: script
|
|
config:
|
|
command: "echo hello"
|
|
"#;
|
|
let dag = load_template(yaml).unwrap();
|
|
assert_eq!(dag.nodes.len(), 1);
|
|
assert!(dag.edges.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_07_invalid_yaml_rejected() {
|
|
let yaml = "not: valid: yaml: {{{";
|
|
let result = load_template(yaml);
|
|
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn tpl_08_all_known_node_types() {
|
|
for nt in KNOWN_NODE_TYPES {
|
|
let yaml = format!(
|
|
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
|
|
nt
|
|
);
|
|
let result = load_template(&yaml);
|
|
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
|
|
}
|
|
}
|
|
}
|