use std::collections::{HashMap, HashSet};
use crate::parser::ast::{ParseResult, Statement, WorkflowDecl};
use crate::parser::errors::ParseError;
pub struct FileWorkflows {
pub filename: String,
pub workflows: Vec<WorkflowDecl>,
}
pub fn link(files: Vec<FileWorkflows>) -> ParseResult {
let mut errors: Vec<ParseError> = Vec::new();
let merged = merge_workflows(&files, &mut errors);
let name_set: HashSet<&str> = merged.iter().map(|w| w.name.as_str()).collect();
resolve_references(&merged, &name_set, &mut errors);
detect_cycles(&merged, &mut errors);
if !errors.is_empty() {
return ParseResult::Err { errors };
}
ParseResult::Ok { workflows: merged }
}
fn merge_workflows(files: &[FileWorkflows], errors: &mut Vec<ParseError>) -> Vec<WorkflowDecl> {
let mut merged: Vec<WorkflowDecl> = Vec::new();
let mut seen: HashMap<String, &WorkflowDecl> = HashMap::new();
for file in files {
for wf in &file.workflows {
if let Some(existing) = seen.get(&wf.name) {
let loc_existing = if existing.file == wf.file {
format!("line {}", existing.line)
} else {
format!("{}:{}", existing.file, existing.line)
};
errors.push(ParseError::new(
&wf.file,
wf.line,
wf.column,
format!(
"duplicate workflow name \"{}\" (first defined at {})",
wf.name, loc_existing
),
));
} else {
seen.insert(wf.name.clone(), wf);
merged.push(wf.clone());
}
}
}
merged
}
fn resolve_references(
workflows: &[WorkflowDecl],
name_set: &HashSet<&str>,
errors: &mut Vec<ParseError>,
) {
for wf in workflows {
resolve_statements_refs(&wf.body, name_set, &wf.file, errors);
}
}
fn resolve_statements_refs(
statements: &[Statement],
name_set: &HashSet<&str>,
file: &str,
errors: &mut Vec<ParseError>,
) {
for stmt in statements {
for name in stmt.referenced_names() {
if !name_set.contains(name) {
errors.push(ParseError::new(
file,
stmt.line(),
stmt.column(),
format!("unresolved workflow reference \"{}\"", name),
));
}
}
if let Some(body) = stmt.body() {
resolve_statements_refs(body, name_set, file, errors);
}
}
}
fn detect_cycles(workflows: &[WorkflowDecl], errors: &mut Vec<ParseError>) {
let mut graph: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut decl_map: HashMap<&str, &WorkflowDecl> = HashMap::new();
for wf in workflows {
decl_map.insert(&wf.name, wf);
let mut callees: HashSet<&str> = HashSet::new();
collect_callees(&wf.body, &mut callees);
graph.insert(&wf.name, callees);
}
let mut state: HashMap<&str, u8> = HashMap::new();
let mut path: Vec<&str> = Vec::new();
for wf in workflows {
state.insert(&wf.name, 0);
}
for wf in workflows {
if state.get(wf.name.as_str()) == Some(&0) {
dfs(&wf.name, &graph, &mut state, &mut path, &decl_map, errors);
}
}
}
fn dfs<'a>(
node: &'a str,
graph: &HashMap<&'a str, HashSet<&'a str>>,
state: &mut HashMap<&'a str, u8>,
path: &mut Vec<&'a str>,
decl_map: &HashMap<&'a str, &WorkflowDecl>,
errors: &mut Vec<ParseError>,
) {
state.insert(node, 1); path.push(node);
if let Some(callees) = graph.get(node) {
for &callee in callees {
let callee_state = state.get(callee).copied().unwrap_or(0);
if callee_state == 1 {
if let Some(cycle_start) = path.iter().position(|&n| n == callee) {
let mut cycle: Vec<&str> = path[cycle_start..].to_vec();
cycle.push(callee);
if let Some(decl) = decl_map.get(node) {
errors.push(ParseError::new(
&decl.file,
decl.line,
decl.column,
format!("circular workflow reference: {}", cycle.join(" -> ")),
));
}
}
} else if callee_state == 0 {
dfs(callee, graph, state, path, decl_map, errors);
}
}
}
path.pop();
state.insert(node, 2); }
fn collect_callees<'a>(statements: &'a [Statement], callees: &mut HashSet<&'a str>) {
for stmt in statements {
for name in stmt.referenced_names() {
callees.insert(name);
}
if let Some(body) = stmt.body() {
collect_callees(body, callees);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::{ExecBlock, RunStatement};
use crate::parser::lexer::lex;
use crate::parser::parser::parse;
fn make_exec_workflow(name: &str, file: &str) -> WorkflowDecl {
WorkflowDecl {
name: name.to_string(),
body: vec![Statement::Exec(ExecBlock {
harness: "h".to_string(),
prompt: Some("test".to_string()),
prompt_file: None,
args: None,
line: 2,
column: 3,
})],
file: file.to_string(),
line: 1,
column: 1,
}
}
fn make_run_workflow(name: &str, target: &str, file: &str) -> WorkflowDecl {
WorkflowDecl {
name: name.to_string(),
body: vec![Statement::Run(RunStatement {
workflow_name: target.to_string(),
line: 2,
column: 3,
})],
file: file.to_string(),
line: 1,
column: 1,
}
}
fn parse_and_link(source: &str) -> ParseResult {
let lex_result = lex(source, "test.7");
assert!(
lex_result.errors.is_empty(),
"Lex errors: {:?}",
lex_result.errors
);
let parse_result = parse(lex_result.tokens, "test.7");
let wfs = match parse_result {
ParseResult::Ok { workflows } => workflows,
ParseResult::Err { errors } => panic!("Parse failed: {:?}", errors),
};
link(vec![FileWorkflows {
filename: "test.7".to_string(),
workflows: wfs,
}])
}
#[test]
fn test_link_single_file_ok() {
let files = vec![FileWorkflows {
filename: "test.7".to_string(),
workflows: vec![make_exec_workflow("main", "test.7")],
}];
let result = link(files);
assert!(result.is_ok());
assert_eq!(result.workflows().unwrap().len(), 1);
}
#[test]
fn test_link_multi_file_ok() {
let files = vec![
FileWorkflows {
filename: "a.7".to_string(),
workflows: vec![make_exec_workflow("greet", "a.7")],
},
FileWorkflows {
filename: "b.7".to_string(),
workflows: vec![make_run_workflow("main", "greet", "b.7")],
},
];
let result = link(files);
assert!(result.is_ok());
assert_eq!(result.workflows().unwrap().len(), 2);
}
#[test]
fn test_link_duplicate_name() {
let files = vec![
FileWorkflows {
filename: "a.7".to_string(),
workflows: vec![make_exec_workflow("dup", "a.7")],
},
FileWorkflows {
filename: "b.7".to_string(),
workflows: vec![make_exec_workflow("dup", "b.7")],
},
];
let result = link(files);
assert!(!result.is_ok());
let errors = result.errors().unwrap();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("duplicate workflow name"));
}
#[test]
fn test_link_unresolved_reference() {
let files = vec![FileWorkflows {
filename: "test.7".to_string(),
workflows: vec![make_run_workflow("main", "nonexistent", "test.7")],
}];
let result = link(files);
assert!(!result.is_ok());
let errors = result.errors().unwrap();
assert!(errors[0].message.contains("unresolved workflow reference"));
}
#[test]
fn test_link_circular_reference() {
let files = vec![FileWorkflows {
filename: "test.7".to_string(),
workflows: vec![
make_run_workflow("a", "b", "test.7"),
make_run_workflow("b", "a", "test.7"),
],
}];
let result = link(files);
assert!(!result.is_ok());
let errors = result.errors().unwrap();
assert!(errors.iter().any(|e| e.message.contains("circular")));
}
#[test]
fn test_link_valid_e2e() {
let result = parse_and_link(
"version 1\nworkflow greet\n exec\n harness: h\n prompt: \"hi\"\nworkflow main\n run greet\n",
);
assert!(result.is_ok());
}
#[test]
fn test_link_unknown_reference_e2e() {
let result = parse_and_link("version 1\nworkflow main\n run nonexistent\n");
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("unresolved"));
}
#[test]
fn test_link_duplicate_name_e2e() {
let lex1 = lex(
"version 1\nworkflow dup\n exec\n harness: h\n prompt: \"a\"\n",
"a.7",
);
let parse1 = parse(lex1.tokens, "a.7");
let wfs1 = parse1.workflows().unwrap().clone();
let lex2 = lex(
"version 1\nworkflow dup\n exec\n harness: h\n prompt: \"b\"\n",
"b.7",
);
let parse2 = parse(lex2.tokens, "b.7");
let wfs2 = parse2.workflows().unwrap().clone();
let result = link(vec![
FileWorkflows {
filename: "a.7".to_string(),
workflows: wfs1,
},
FileWorkflows {
filename: "b.7".to_string(),
workflows: wfs2,
},
]);
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("duplicate"));
}
#[test]
fn test_link_circular_reference_e2e() {
let result = parse_and_link("version 1\nworkflow a\n run b\nworkflow b\n run a\n");
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("circular"));
}
}