use petgraph::algo::{is_cyclic_directed, toposort};
use petgraph::visit::EdgeRef;
use petgraph::Direction;
use regex::Regex;
use serde::Serialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use crate::core::graph::{self, NodeType, WorkflowGraph};
use petgraph::graph::NodeIndex;
#[derive(Debug, Default)]
pub struct ProjectContext {
pub prompt_slugs: HashSet<String>,
pub flow_paths: HashSet<PathBuf>,
pub base_dir: PathBuf,
pub file_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
pub enum ValidationSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationIssue {
pub severity: ValidationSeverity,
pub code: String,
pub message: String,
pub node_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationIssue>,
pub warnings: Vec<ValidationIssue>,
}
impl Default for ValidationResult {
fn default() -> Self {
Self::new()
}
}
impl ValidationResult {
pub fn new() -> Self {
ValidationResult {
is_valid: true,
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn add_error(&mut self, code: &str, message: &str, node_id: Option<&str>) {
self.is_valid = false;
self.errors.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: code.to_string(),
message: message.to_string(),
node_id: node_id.map(|s| s.to_string()),
});
}
pub fn add_warning(&mut self, code: &str, message: &str, node_id: Option<&str>) {
self.warnings.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: code.to_string(),
message: message.to_string(),
node_id: node_id.map(|s| s.to_string()),
});
}
pub fn error_count(&self) -> usize {
self.errors.len()
}
pub fn warning_count(&self) -> usize {
self.warnings.len()
}
pub fn format_report(&self, file_display: &str) -> String {
let mut out = String::new();
if !self.is_valid {
out.push_str(&format!(
"\x1b[1;31merror\x1b[0m: validation failed for '{}'\n",
file_display
));
} else if !self.warnings.is_empty() {
out.push_str(&format!("\x1b[1;33mwarning\x1b[0m: '{}'\n", file_display));
}
for issue in &self.errors {
let node_suffix = issue
.node_id
.as_ref()
.map(|n| format!(" in [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" \x1b[31m[{}]\x1b[0m {}{}\n",
issue.code, issue.message, node_suffix
));
}
for issue in &self.warnings {
let node_suffix = issue
.node_id
.as_ref()
.map(|n| format!(" in [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" \x1b[33m[{}]\x1b[0m {}{}\n",
issue.code, issue.message, node_suffix
));
}
out
}
pub fn to_error_json(&self) -> serde_json::Value {
json!({
"error": "Workflow validation failed",
"errors": self.errors,
"warnings": self.warnings,
})
}
}
pub struct WorkflowValidator;
impl WorkflowValidator {
pub fn validate(graph: &WorkflowGraph) -> ValidationResult {
Self::validate_with_scope(graph, &HashSet::new(), None)
}
pub fn validate_with_project(
graph: &WorkflowGraph,
project: &ProjectContext,
) -> ValidationResult {
Self::validate_with_scope(graph, &HashSet::new(), Some(project))
}
fn validate_with_scope(
graph: &WorkflowGraph,
parent_vars: &HashSet<String>,
project: Option<&ProjectContext>,
) -> ValidationResult {
let mut result = ValidationResult::new();
Self::check_entry_node(graph, &mut result);
Self::check_cycles(graph, &mut result);
Self::check_unreachable_nodes(graph, &mut result);
Self::check_terminal_nodes(graph, &mut result);
Self::check_empty_workflow(graph, &mut result);
Self::check_node_references(graph, &mut result);
Self::check_nested_workflows(graph, parent_vars, &mut result);
Self::check_variable_references(graph, parent_vars, &mut result);
Self::check_switch_routes(graph, &mut result);
Self::check_edge_conditions(graph, &mut result);
Self::check_function_definitions(graph, parent_vars, &mut result);
Self::check_function_calls(graph, &mut result);
Self::check_on_tool_references(graph, &mut result);
if let Some(project) = project {
Self::check_prompt_references(graph, project, &mut result);
Self::check_flow_imports(graph, project, &mut result);
Self::check_resource_patterns(graph, project, &mut result);
#[cfg(not(target_arch = "wasm32"))]
Self::check_lib_imports(graph, project, &mut result);
}
result
}
fn check_entry_node(graph: &WorkflowGraph, result: &mut ValidationResult) {
if graph.entry_node.is_empty() {
if graph.graph.node_count() > 0 {
result.add_warning(
"W001",
"No entry node specified; using first node as entry point",
None,
);
}
} else if !graph.node_map.contains_key(&graph.entry_node) {
result.add_error(
"E001",
&format!(
"Entry node '{}' does not exist in the graph",
graph.entry_node
),
Some(&graph.entry_node),
);
}
}
fn check_cycles(graph: &WorkflowGraph, result: &mut ValidationResult) {
if is_cyclic_directed(&graph.graph) {
match toposort(&graph.graph, None) {
Ok(_) => {} Err(cycle) => {
let node = &graph.graph[cycle.node_id()];
result.add_error(
"E002",
&format!(
"Cycle detected involving node '{}'. Workflows must be acyclic (DAG).",
node.id
),
Some(&node.id),
);
}
}
}
}
fn check_unreachable_nodes(graph: &WorkflowGraph, result: &mut ValidationResult) {
if graph.graph.node_count() == 0 {
return;
}
let entry_id = if graph.entry_node.is_empty() {
graph
.graph
.node_indices()
.next()
.map(|idx| graph.graph[idx].id.clone())
.unwrap_or_default()
} else {
graph.entry_node.clone()
};
if let Some(&entry_idx) = graph.node_map.get(&entry_id) {
let mut reachable = HashSet::new();
let mut stack = vec![entry_idx];
while let Some(idx) = stack.pop() {
if reachable.insert(idx) {
for neighbor in graph.graph.neighbors_directed(idx, Direction::Outgoing) {
if !reachable.contains(&neighbor) {
stack.push(neighbor);
}
}
}
}
for idx in graph.graph.node_indices() {
if !reachable.contains(&idx) {
let node = &graph.graph[idx];
if graph::is_test_node_id(&node.id) {
continue;
}
if Self::is_test_only_descendant(graph, idx) {
continue;
}
result.add_warning(
"W002",
&format!("Node '{}' is not reachable from entry node", node.id),
Some(&node.id),
);
}
}
}
}
fn is_test_only_descendant(wf: &WorkflowGraph, idx: NodeIndex) -> bool {
let mut stack = vec![idx];
let mut visited = HashSet::new();
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
let in_edges: Vec<_> = wf
.graph
.edges_directed(current, Direction::Incoming)
.collect();
if in_edges.is_empty() {
if !graph::is_test_node_id(&wf.graph[current].id) {
return false;
}
} else {
for edge in in_edges {
stack.push(edge.source());
}
}
}
true
}
fn check_terminal_nodes(graph: &WorkflowGraph, result: &mut ValidationResult) {
if graph.graph.node_count() > 1 {
let has_terminal = graph.graph.node_indices().any(|idx| {
graph
.graph
.neighbors_directed(idx, Direction::Outgoing)
.count()
== 0
});
if !has_terminal {
result.add_warning(
"W003",
"No terminal nodes found (nodes with no outgoing edges). All paths may loop.",
None,
);
}
}
}
fn check_empty_workflow(graph: &WorkflowGraph, result: &mut ValidationResult) {
if graph.graph.node_count() == 0 {
result.add_error("E004", "Workflow contains no nodes", None);
}
}
fn check_node_references(graph: &WorkflowGraph, result: &mut ValidationResult) {
let mut known_tools: HashSet<&str> = [
"chat",
"p",
"memory_search",
"history",
"execute_workflow",
"fetch_url",
"fetch",
"http_request",
"oauth_token",
"timer",
"notify",
"reply",
"print",
"return",
"feishu_webhook",
"serve",
"response",
"read_file",
"write_file",
"edit_file",
"glob",
"grep",
"bash",
"sh",
"assert",
"config",
"db.connect",
"db.disconnect",
"db.query",
"db.exec",
"db.find",
"db.find_one",
"db.create",
"db.create_many",
"db.upsert",
"db.update",
"db.delete",
"db.count",
"db.aggregate",
"db.begin",
"db.commit",
"db.rollback",
"db.create_table",
"db.drop_table",
"db.alter_table",
"db.tables",
"db.columns",
"vector_create_space",
"vector_upsert",
"vector_search",
"vector_list_spaces",
"vector_delete_space",
"vector_delete",
"history.load",
"history.append",
"history.replace",
"history.trim",
"history.clear",
"history.stats",
"history.list_chats",
]
.iter()
.copied()
.collect();
let func_names: Vec<String> = graph.functions.keys().cloned().collect();
for name in &func_names {
known_tools.insert(name.as_str());
}
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
if let NodeType::Task(action) = &node.node_type {
if !known_tools.contains(action.name.as_str())
&& !graph.classes.contains_key(&action.name)
{
result.add_warning(
"W004",
&format!(
"Unknown tool '{}'. Ensure it's defined in MCP servers or libs.",
action.name
),
Some(&node.id),
);
}
Self::check_tool_params(&action.name, &action.params, &node.id, result);
}
}
}
fn check_tool_params(
tool_name: &str,
params: &HashMap<String, String>,
node_id: &str,
result: &mut ValidationResult,
) {
match tool_name {
"chat" => {
if !params.contains_key("message") {
result.add_error("E007", "chat() requires 'message' parameter", Some(node_id));
}
if !params.contains_key("agent") && !params.contains_key("system_prompt") {
result.add_warning(
"W005",
"chat() should have 'agent' or 'system_prompt' parameter",
Some(node_id),
);
}
}
"p" => {
if !params.contains_key("slug") && !params.contains_key("file") {
result.add_error(
"E008",
"p() requires 'slug' or 'file' parameter",
Some(node_id),
);
}
}
"memory_search" => {
if !params.contains_key("query") {
result.add_error(
"E009",
"memory_search() requires 'query' parameter",
Some(node_id),
);
}
}
"history" => {
if !params.contains_key("chat_id") {
result.add_error(
"E010",
"history() requires 'chat_id' parameter",
Some(node_id),
);
}
}
"fetch" | "fetch_url" => {
if !params.contains_key("url") {
result.add_error(
"E011",
&format!("{}() requires 'url' parameter", tool_name),
Some(node_id),
);
}
}
"read_file" => {
if !params.contains_key("file_path") {
result.add_error(
"E012",
"read_file() requires 'file_path' parameter",
Some(node_id),
);
}
}
"write_file" => {
if !params.contains_key("file_path") {
result.add_error(
"E012",
"write_file() requires 'file_path' parameter",
Some(node_id),
);
}
if !params.contains_key("content") {
result.add_error(
"E012",
"write_file() requires 'content' parameter",
Some(node_id),
);
}
}
"edit_file" => {
for req in &["file_path", "old_string", "new_string"] {
if !params.contains_key(*req) {
result.add_error(
"E012",
&format!("edit_file() requires '{}' parameter", req),
Some(node_id),
);
}
}
}
"bash" | "sh" => {
if !params.contains_key("command") && !params.contains_key("cmd") {
result.add_error(
"E013",
&format!("{}() requires 'command' or 'cmd' parameter", tool_name),
Some(node_id),
);
}
}
"feishu_webhook" => {
if !params.contains_key("message") {
result.add_error(
"E014",
"feishu_webhook() requires 'message' parameter",
Some(node_id),
);
}
}
"notify" => {
if !params.contains_key("message") && !params.contains_key("status") {
result.add_error(
"E015",
"notify() requires 'message' or 'status' parameter",
Some(node_id),
);
}
}
_ => {}
}
}
fn check_variable_references(
graph: &WorkflowGraph,
parent_vars: &HashSet<String>,
result: &mut ValidationResult,
) {
let mut valid_prefixes: HashSet<String> =
["input", "output", "ctx", "reply", "error", "config", "bot"]
.iter()
.map(|s| s.to_string())
.collect();
valid_prefixes.extend(parent_vars.iter().cloned());
let mut node_ids: HashSet<String> = HashSet::new();
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
valid_prefixes.insert(node.id.clone());
node_ids.insert(node.id.clone());
if let Some(root) = node.id.split('.').next() {
valid_prefixes.insert(root.to_string());
}
if let NodeType::Foreach { item, .. } = &node.node_type {
valid_prefixes.insert(item.clone());
}
}
for func_name in graph.functions.keys() {
valid_prefixes.insert(func_name.clone());
}
let topo_order: HashMap<String, usize> = match toposort(&graph.graph, None) {
Ok(sorted) => sorted
.iter()
.enumerate()
.map(|(i, &idx)| (graph.graph[idx].id.clone(), i))
.collect(),
Err(_) => HashMap::new(), };
let dollar_var_re = Regex::new(r"\$([a-zA-Z_][a-zA-Z0-9_.]*)").unwrap();
let bare_var_re =
Regex::new(r"(?:^|[^a-zA-Z0-9_.])([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z0-9_.]+)").unwrap();
let bare_start_re = Regex::new(r"^([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z0-9_.]+)").unwrap();
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
if let NodeType::Task(action) = &node.node_type {
for param_value in action.params.values() {
let mut var_refs: Vec<String> = Vec::new();
for cap in dollar_var_re.captures_iter(param_value) {
var_refs.push(cap[1].to_string());
}
let unquoted = strip_quoted_strings(param_value);
for cap in bare_var_re.captures_iter(&unquoted) {
let var_path = cap[1].to_string();
if !var_refs.contains(&var_path) {
var_refs.push(var_path);
}
}
for cap in bare_start_re.captures_iter(&unquoted) {
let var_path = cap[1].to_string();
if !var_refs.contains(&var_path) {
var_refs.push(var_path);
}
}
for var_path in &var_refs {
let root = var_path.split('.').next().unwrap_or("");
if !valid_prefixes.contains(root) {
result.add_warning(
"W006",
&format!(
"Variable '{}' has unknown prefix '{}'. Known: input, output, ctx, reply, error, config",
var_path, root
),
Some(&node.id),
);
} else if node_ids.contains(root) && !topo_order.is_empty() {
if let (Some(&ref_pos), Some(&cur_pos)) =
(topo_order.get(root), topo_order.get(&node.id))
{
if ref_pos >= cur_pos {
result.add_warning(
"W010",
&format!(
"Variable '{}' references node '{}' which is not a DAG predecessor of '{}' — output may not be available",
var_path, root, node.id
),
Some(&node.id),
);
}
}
}
}
}
}
}
}
fn check_switch_routes(graph: &WorkflowGraph, result: &mut ValidationResult) {
for (source_id, route) in &graph.switch_routes {
let has_default = route.cases.iter().any(|c| c.value.is_none());
if !has_default {
result.add_warning(
"W007",
&format!(
"Switch at [{}] has no 'default' case. Unmatched values will have no route.",
source_id
),
Some(source_id),
);
}
let mut seen: HashSet<&str> = HashSet::new();
for case in &route.cases {
if let Some(ref val) = case.value {
if !seen.insert(val.as_str()) {
result.add_warning(
"W008",
&format!(
"Switch at [{}] has duplicate case value '{}'",
source_id, val
),
Some(source_id),
);
}
}
}
}
}
fn check_edge_conditions(graph: &WorkflowGraph, result: &mut ValidationResult) {
for edge in graph.graph.edge_weights() {
if let Some(ref condition) = edge.condition {
if condition.trim().is_empty() {
result.add_warning("W009", "Edge has an empty condition expression", None);
}
}
}
}
fn check_nested_workflows(
graph: &WorkflowGraph,
parent_vars: &HashSet<String>,
result: &mut ValidationResult,
) {
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
match &node.node_type {
NodeType::Foreach { item, body, .. } => {
let mut nested_vars = parent_vars.clone();
nested_vars.insert(item.clone());
let nested_result = Self::validate_with_scope(body, &nested_vars, None);
for err in nested_result.errors {
result.add_error(
&format!("{}/nested", err.code),
&format!("[in foreach '{}'] {}", node.id, err.message),
err.node_id.as_deref(),
);
}
for warn in nested_result.warnings {
result.add_warning(
&format!("{}/nested", warn.code),
&format!("[in foreach '{}'] {}", node.id, warn.message),
warn.node_id.as_deref(),
);
}
}
NodeType::Loop { body, .. } => {
let nested_result = Self::validate_with_scope(body, parent_vars, None);
for err in nested_result.errors {
result.add_error(
&format!("{}/nested", err.code),
&format!("[in loop '{}'] {}", node.id, err.message),
err.node_id.as_deref(),
);
}
for warn in nested_result.warnings {
result.add_warning(
&format!("{}/nested", warn.code),
&format!("[in loop '{}'] {}", node.id, warn.message),
warn.node_id.as_deref(),
);
}
}
_ => {}
}
}
}
fn check_function_definitions(
graph: &WorkflowGraph,
parent_vars: &HashSet<String>,
result: &mut ValidationResult,
) {
for (func_name, func_def) in &graph.functions {
let mut func_vars = parent_vars.clone();
for param in &func_def.params {
func_vars.insert(param.clone());
}
let nested_result = Self::validate_with_scope(&func_def.body, &func_vars, None);
for err in nested_result.errors {
result.add_error(
&format!("{}/nested", err.code),
&format!("[in function '{}'] {}", func_name, err.message),
err.node_id.as_deref(),
);
}
for warn in nested_result.warnings {
result.add_warning(
&format!("{}/nested", warn.code),
&format!("[in function '{}'] {}", func_name, warn.message),
warn.node_id.as_deref(),
);
}
}
}
fn check_function_calls(graph: &WorkflowGraph, result: &mut ValidationResult) {
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
if let NodeType::Task(action) = &node.node_type {
if let Some(func_def) = graph.functions.get(&action.name) {
let expected_params: HashSet<&str> =
func_def.params.iter().map(|s| s.as_str()).collect();
let actual_params: HashSet<&str> =
action.params.keys().map(|s| s.as_str()).collect();
if actual_params.len() > expected_params.len() {
result.add_error(
"E017",
&format!(
"Function '{}' expects at most {} parameter(s) ({}) but got {}",
action.name,
expected_params.len(),
func_def.params.join(", "),
actual_params.len()
),
Some(&node.id),
);
}
for param_name in &actual_params {
if !expected_params.contains(param_name) {
result.add_error(
"E018",
&format!(
"Function '{}' has no parameter named '{}'. Expected: {}",
action.name,
param_name,
func_def.params.join(", ")
),
Some(&node.id),
);
}
}
}
}
}
}
fn check_on_tool_references(graph: &WorkflowGraph, result: &mut ValidationResult) {
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
if let NodeType::Task(action) = &node.node_type {
if action.name == "chat" {
if let Some(on_tool_val) = action.params.get("on_tool") {
let trimmed = on_tool_val.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let ref_name = &trimmed[1..trimmed.len() - 1];
if !graph.node_map.contains_key(ref_name)
&& !graph.functions.contains_key(ref_name)
{
result.add_error(
"E019",
&format!(
"on_tool=[{}] references '{}' which is neither a node nor a function in this workflow",
ref_name, ref_name
),
Some(&node.id),
);
}
}
}
}
}
}
}
fn check_prompt_references(
graph: &WorkflowGraph,
project: &ProjectContext,
result: &mut ValidationResult,
) {
for idx in graph.graph.node_indices() {
let node = &graph.graph[idx];
if let NodeType::Task(action) = &node.node_type {
if action.name == "p" {
if let Some(slug_val) = action.params.get("slug") {
let slug = slug_val.trim().trim_matches('"');
if !slug.starts_with('$') && !project.prompt_slugs.contains(slug) {
result.add_warning(
"W013",
&format!(
"p(slug=\"{}\") — prompt slug '{}' not found in project .jgx files",
slug, slug
),
Some(&node.id),
);
}
}
}
}
}
}
fn check_flow_imports(
graph: &WorkflowGraph,
project: &ProjectContext,
result: &mut ValidationResult,
) {
for (alias, rel_path) in &graph.flow_imports {
let path = rel_path.trim().trim_matches('"');
let abs_path = if std::path::Path::new(path).is_absolute() {
PathBuf::from(path)
} else if !project.file_dir.as_os_str().is_empty() {
project.file_dir.join(path)
} else {
project.base_dir.join(path)
};
if !abs_path.exists() {
result.add_error(
"E020",
&format!(
"Flow import '{}' references path '{}' which does not exist",
alias, rel_path
),
None,
);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn check_lib_imports(
graph: &WorkflowGraph,
project: &ProjectContext,
result: &mut ValidationResult,
) {
use crate::registry::package::is_registry_import;
for (namespace, rel_path) in &graph.lib_imports {
let path = rel_path.trim().trim_matches('"');
if path.starts_with("std/") {
let stdlib_name = path.strip_prefix("std/").unwrap().trim_end_matches(".jg");
if crate::core::stdlib::get(stdlib_name).is_some() {
continue;
}
}
if is_registry_import(path) {
let jg_modules_path = project.base_dir.join("jg_modules").join(namespace);
if !jg_modules_path.exists() {
result.add_warning(
"W022",
&format!(
"Registry package '{}' is not installed. Run 'juglans add {}' or it will be auto-installed at runtime.",
rel_path, rel_path
),
None,
);
}
continue;
}
let abs_path = if std::path::Path::new(path).is_absolute() {
PathBuf::from(path)
} else if !project.file_dir.as_os_str().is_empty() {
project.file_dir.join(path)
} else {
project.base_dir.join(path)
};
if !abs_path.exists() {
result.add_error(
"E021",
&format!(
"Lib import '{}' references path '{}' which does not exist",
namespace, rel_path
),
None,
);
}
}
}
fn check_resource_patterns(
graph: &WorkflowGraph,
project: &ProjectContext,
result: &mut ValidationResult,
) {
let all_patterns: Vec<(&str, &str)> = graph
.prompt_patterns
.iter()
.map(|p| (p.as_str(), "prompts"))
.chain(graph.tool_patterns.iter().map(|p| (p.as_str(), "tools")))
.collect();
for (pattern, kind) in all_patterns {
if pattern.starts_with('$') || pattern.starts_with("@/") {
continue;
}
let resolved = if !std::path::Path::new(pattern).is_absolute()
&& !project.file_dir.as_os_str().is_empty()
{
project.file_dir.join(pattern).to_string_lossy().to_string()
} else {
pattern.to_string()
};
match glob::glob(&resolved) {
Ok(paths) => {
if paths.count() == 0 {
result.add_warning(
"W014",
&format!("{} pattern '{}' did not match any files", kind, pattern),
None,
);
}
}
Err(_) => {
result.add_warning(
"W014",
&format!("{} pattern '{}' is not a valid glob", kind, pattern),
None,
);
}
}
}
}
}
fn strip_quoted_strings(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut in_quote = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && in_quote {
chars.next();
result.push(' ');
result.push(' ');
} else if c == '"' {
in_quote = !in_quote;
result.push(' ');
} else if in_quote {
result.push(' ');
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::parser::GraphParser;
#[test]
fn test_valid_workflow() {
let content = r#"
[start]: chat(agent="default", message="hello")
[end]: notify(message="done")
[start] -> [end]
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(result.is_valid, "errors: {:?}", result.errors);
}
#[test]
fn test_missing_required_param_chat() {
let content = r#"
[start]: chat(agent="test")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "E007"));
}
#[test]
fn test_missing_required_param_fetch() {
let content = r#"
[start]: fetch(method="GET")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| e.code == "E011"));
}
#[test]
fn test_unknown_variable_prefix() {
let content = r#"
[start]: notify(message=unknown.var)
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(result.warnings.iter().any(|w| w.code == "W006"));
}
#[test]
fn test_valid_variable_prefix() {
let content = r#"
[start]: notify(message=input.query)
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(!result.warnings.iter().any(|w| w.code == "W006"));
}
#[test]
fn test_switch_missing_default() {
let content = r#"
[start]: notify(message="test")
[a]: notify(message="a")
[b]: notify(message="b")
[start] -> switch type {
"a": [a]
"b": [b]
}
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(result.warnings.iter().any(|w| w.code == "W007"));
}
#[test]
fn test_switch_with_default_no_warning() {
let content = r#"
[start]: notify(message="test")
[a]: notify(message="a")
[fallback]: notify(message="fb")
[start] -> switch type {
"a": [a]
default: [fallback]
}
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(!result.warnings.iter().any(|w| w.code == "W007"));
}
#[test]
fn test_function_call_correct_params() {
let content = r#"
[greet(name)]: bash(command="echo " + name)
[step1]: greet(name="world")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result
.errors
.iter()
.any(|e| e.code == "E017" || e.code == "E018"),
"errors: {:?}",
result.errors
);
}
#[test]
fn test_function_call_too_many_params() {
let content = r#"
[greet(name)]: bash(command="echo " + name)
[step1]: greet(name="world", extra="oops")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
result
.errors
.iter()
.any(|e| e.code == "E017" || e.code == "E018"),
"Expected E017/E018 for too many params, got: {:?}",
result.errors
);
}
#[test]
fn test_function_call_fewer_params_allowed() {
let content = r#"
[greet(name, greeting)]: bash(command="echo " + name)
[step1]: greet(name="world")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result.errors.iter().any(|e| e.code == "E017"),
"Fewer params should be allowed (optional params), got: {:?}",
result.errors
);
}
#[test]
fn test_function_call_unknown_param_name() {
let content = r#"
[greet(name)]: bash(command="echo " + name)
[step1]: greet(unknown="world")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
result.errors.iter().any(|e| e.code == "E018"),
"Expected E018 for unknown param name, got: {:?}",
result.errors
);
}
#[test]
fn test_function_not_w004_unknown() {
let content = r#"
[greet(name)]: bash(command="echo " + name)
[step1]: greet(name="world")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result
.warnings
.iter()
.any(|w| w.code == "W004" && w.message.contains("greet")),
"Function call should not trigger W004, got: {:?}",
result.warnings
);
}
#[test]
fn test_function_body_validation() {
let content = r#"
[bad_func(x)]: bash(command=unknown_var.path)
[step1]: bad_func(x="test")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
result
.warnings
.iter()
.any(|w| w.code.starts_with("W006") && w.message.contains("function")),
"Expected nested W006 in function body, got warnings: {:?}",
result.warnings
);
}
#[test]
fn test_function_body_valid_param_usage() {
let content = r#"
[greet(name)]: bash(command=name.value)
[step1]: greet(name="world")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result
.warnings
.iter()
.any(|w| w.code.starts_with("W006") && w.message.contains("name")),
"Function param should be valid prefix, got: {:?}",
result.warnings
);
}
#[test]
fn test_on_tool_valid_reference() {
let content = r#"
[handler]: bash(command="echo tool")
[chat_node]: chat(agent="test", message="hi", on_tool=[handler])
[handler] -> [chat_node]
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result.errors.iter().any(|e| e.code == "E019"),
"on_tool=[handler] should be valid, got: {:?}",
result.errors
);
}
#[test]
fn test_on_tool_invalid_reference() {
let content = r#"
[chat_node]: chat(agent="test", message="hi", on_tool=[nonexistent])
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
result.errors.iter().any(|e| e.code == "E019"),
"Expected E019 for nonexistent on_tool ref, got: {:?}",
result.errors
);
}
#[test]
fn test_on_tool_function_reference() {
let content = r#"
[handle(name, args)]: bash(command="echo " + name)
[chat_node]: chat(agent="test", message="hi", on_tool=[handle])
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result.errors.iter().any(|e| e.code == "E019"),
"on_tool=[handle] referencing function should be valid, got: {:?}",
result.errors
);
}
#[test]
fn test_variable_dag_predecessor_valid() {
let content = r#"
[step1]: notify(message="hello")
[step2]: notify(message=step1.output)
[step1] -> [step2]
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result.warnings.iter().any(|w| w.code == "W010"),
"step1 is predecessor of step2, no W010 expected. Got: {:?}",
result.warnings
);
}
#[test]
fn test_variable_dag_predecessor_invalid() {
let content = r#"
[step1]: notify(message="hello")
[step2]: notify(message=step3.output)
[step3]: notify(message="world")
[step1] -> [step2] -> [step3]
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
result.warnings.iter().any(|w| w.code == "W010"),
"step3 is NOT predecessor of step2, W010 expected. Got: {:?}",
result.warnings
);
}
#[test]
fn test_prompt_reference_not_found() {
let content = r#"
[start]: p(slug="missing-prompt", file="x")
"#;
let graph = GraphParser::parse(content).unwrap();
let project = ProjectContext::default();
let result = WorkflowValidator::validate_with_project(&graph, &project);
assert!(
result.warnings.iter().any(|w| w.code == "W013"),
"Prompt missing, W013 expected. Got: {:?}",
result.warnings
);
}
#[test]
fn test_flow_import_missing_path() {
let content = r#"
flows: { auth: "./nonexistent.jg" }
[start]: notify(message="hello")
"#;
let graph = GraphParser::parse(content).unwrap();
let project = ProjectContext {
base_dir: PathBuf::from("/tmp/nonexistent_dir"),
..Default::default()
};
let result = WorkflowValidator::validate_with_project(&graph, &project);
assert!(
result.errors.iter().any(|e| e.code == "E020"),
"Flow path doesn't exist, E020 expected. Got: {:?}",
result.errors
);
}
#[test]
fn test_known_tools_no_false_positive() {
let content = r#"
[start]: execute_workflow(path="x")
[srv]: serve(port="8080")
[resp]: response(body="ok")
[db]: db.find(table="users", where={})
[start] -> [srv] -> [resp] -> [db]
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
let unknown_w004: Vec<_> = result
.warnings
.iter()
.filter(|w| w.code == "W004")
.collect();
assert!(
unknown_w004.is_empty(),
"execute_workflow/serve/response/db.find should be known. W004s: {:?}",
unknown_w004
);
}
#[test]
fn test_multi_step_function_validation() {
let content = r#"
[build(dir)]: {
bash(command="cd " + dir + " && make");
bash(command="cd " + dir + " && test")
}
[step1]: build(dir="/app")
"#;
let graph = GraphParser::parse(content).unwrap();
let result = WorkflowValidator::validate(&graph);
assert!(
!result
.errors
.iter()
.any(|e| e.code == "E017" || e.code == "E018"),
"Multi-step function call should be valid. Errors: {:?}",
result.errors
);
assert!(
!result
.warnings
.iter()
.any(|w| w.code == "W004" && w.message.contains("build")),
"build should be known function. Warnings: {:?}",
result.warnings
);
}
#[test]
fn test_lib_import_missing_path() {
let content = r#"
libs: { mylib: "./nonexistent_lib.jg" }
[start]: notify(message="hello")
"#;
let graph = GraphParser::parse(content).unwrap();
let project = ProjectContext {
base_dir: PathBuf::from("/tmp/nonexistent_dir"),
..Default::default()
};
let result = WorkflowValidator::validate_with_project(&graph, &project);
assert!(
result.errors.iter().any(|e| e.code == "E021"),
"Lib path doesn't exist, E021 expected. Got: {:?}",
result.errors
);
}
#[test]
fn test_parse_libs_map_form() {
let content = r#"
libs: { db: "./libs/sqlite.jg", http: "./libs/http.jg" }
[start]: notify(message="hello")
"#;
let graph = GraphParser::parse(content).unwrap();
assert_eq!(graph.lib_imports.len(), 2);
assert_eq!(graph.lib_imports.get("db").unwrap(), "./libs/sqlite.jg");
assert_eq!(graph.lib_imports.get("http").unwrap(), "./libs/http.jg");
assert!(!graph.lib_auto_namespaces.contains("db"));
assert!(!graph.lib_auto_namespaces.contains("http"));
}
#[test]
fn test_parse_libs_list_form() {
let content = r#"
libs: ["./libs/sqlite.jg", "./libs/http_client.jg"]
[start]: notify(message="hello")
"#;
let graph = GraphParser::parse(content).unwrap();
assert_eq!(graph.lib_imports.len(), 2);
assert!(graph.lib_imports.contains_key("sqlite"));
assert!(graph.lib_imports.contains_key("http_client"));
assert!(graph.lib_auto_namespaces.contains("sqlite"));
assert!(graph.lib_auto_namespaces.contains("http_client"));
}
}