use std::collections::{HashMap, HashSet};
use crate::error::codes::ErrorCode;
use crate::error::diagnostic::{AgmError, ErrorLocation};
use crate::model::fields::NodeType;
use crate::model::node::Node;
use crate::model::orchestration::ParallelGroup;
fn detect_requires_cycles(groups: &[ParallelGroup], node: &Node, file_name: &str) -> Vec<AgmError> {
let mut errors = Vec::new();
let group_names: HashSet<&str> = groups.iter().map(|g| g.group.as_str()).collect();
let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
for group in groups {
let reqs: Vec<&str> = group
.requires
.as_ref()
.map(|r| {
r.iter()
.filter(|req| group_names.contains(req.as_str()))
.map(|s| s.as_str())
.collect()
})
.unwrap_or_default();
graph.insert(group.group.as_str(), reqs);
}
let mut colors: HashMap<&str, u8> = HashMap::new(); let mut stack: Vec<&str> = Vec::new();
for group in groups {
let gid = group.group.as_str();
if colors.get(gid).copied().unwrap_or(0) != 2 {
dfs_requires(
gid,
&graph,
&mut colors,
&mut stack,
node,
file_name,
&mut errors,
);
}
}
errors
}
fn dfs_requires<'a>(
group_id: &'a str,
graph: &HashMap<&'a str, Vec<&'a str>>,
colors: &mut HashMap<&'a str, u8>,
stack: &mut Vec<&'a str>,
node: &Node,
file_name: &str,
errors: &mut Vec<AgmError>,
) {
colors.insert(group_id, 1); stack.push(group_id);
if let Some(reqs) = graph.get(group_id) {
for &req in reqs {
match colors.get(req).copied().unwrap_or(0) {
1 => {
let start = stack.iter().position(|&g| g == req).unwrap_or(0);
let mut cycle: Vec<&str> = stack[start..].to_vec();
cycle.push(req);
let cycle_path = cycle.join(" -> ");
errors.push(AgmError::new(
ErrorCode::V019,
format!(
"Cycle in orchestration `requires`: `{cycle_path}` in node `{}`",
node.id
),
ErrorLocation::full(file_name, node.span.start_line, &node.id),
));
}
2 => {} _ => {
dfs_requires(req, graph, colors, stack, node, file_name, errors);
}
}
}
}
stack.pop();
colors.insert(group_id, 2); }
#[must_use]
pub fn validate_orchestration(
node: &Node,
all_ids: &HashSet<String>,
file_name: &str,
) -> Vec<AgmError> {
let mut errors = Vec::new();
let line = node.span.start_line;
let id = node.id.as_str();
if node.node_type == NodeType::Orchestration && node.parallel_groups.is_none() {
errors.push(AgmError::new(
ErrorCode::V018,
format!("Orchestration node `{id}` missing required field: `parallel_groups`"),
ErrorLocation::full(file_name, line, id),
));
return errors; }
let groups = match &node.parallel_groups {
Some(g) => g,
None => return errors,
};
let group_names: HashSet<&str> = groups.iter().map(|g| g.group.as_str()).collect();
for group in groups {
if group.group.trim().is_empty() {
errors.push(AgmError::new(
ErrorCode::V018,
format!("Orchestration group in node `{id}` has empty `group` name"),
ErrorLocation::full(file_name, line, id),
));
}
if group.nodes.is_empty() {
errors.push(AgmError::new(
ErrorCode::V018,
format!(
"Orchestration group `{}` in node `{id}` has empty `nodes` list",
group.group
),
ErrorLocation::full(file_name, line, id),
));
}
for ref_node_id in &group.nodes {
if !all_ids.contains(ref_node_id.as_str()) {
errors.push(AgmError::new(
ErrorCode::V018,
format!(
"Orchestration group `{}` references non-existent node `{ref_node_id}`",
group.group
),
ErrorLocation::full(file_name, line, id),
));
}
}
if let Some(ref reqs) = group.requires {
for req in reqs {
if !group_names.contains(req.as_str()) {
errors.push(AgmError::new(
ErrorCode::V018,
format!(
"Orchestration group `{}` in node `{id}` requires non-existent group `{req}`",
group.group
),
ErrorLocation::full(file_name, line, id),
));
}
}
}
}
errors.extend(detect_requires_cycles(groups, node, file_name));
errors
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashSet};
use super::*;
use crate::model::fields::{NodeType, Span};
use crate::model::node::Node;
use crate::model::orchestration::{ParallelGroup, Strategy};
fn make_node(id: &str, node_type: NodeType) -> Node {
Node {
id: id.to_owned(),
node_type,
summary: "a node".to_owned(),
priority: None,
stability: None,
confidence: None,
status: None,
depends: None,
related_to: None,
replaces: None,
conflicts: None,
see_also: None,
items: None,
steps: None,
fields: None,
input: None,
output: None,
detail: None,
rationale: None,
tradeoffs: None,
resolution: None,
examples: None,
notes: None,
code: None,
code_blocks: None,
verify: None,
agent_context: None,
target: None,
execution_status: None,
executed_by: None,
executed_at: None,
execution_log: None,
retry_count: None,
parallel_groups: None,
memory: None,
scope: None,
applies_when: None,
valid_from: None,
valid_until: None,
tags: None,
aliases: None,
keywords: None,
extra_fields: BTreeMap::new(),
span: Span::new(5, 7),
}
}
fn ids(node_ids: &[&str]) -> HashSet<String> {
node_ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_validate_orchestration_valid_returns_empty() {
let mut node = make_node("orch.main", NodeType::Orchestration);
node.parallel_groups = Some(vec![ParallelGroup {
group: "phase1".to_owned(),
nodes: vec!["step.a".to_owned()],
strategy: Strategy::Sequential,
requires: None,
max_concurrency: None,
}]);
let all_ids = ids(&["step.a"]);
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_orchestration_no_parallel_groups_returns_v018() {
let node = make_node("orch.main", NodeType::Orchestration);
let all_ids = HashSet::new();
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V018));
}
#[test]
fn test_validate_orchestration_non_orchestration_no_groups_returns_empty() {
let node = make_node("facts.node", NodeType::Facts);
let all_ids = HashSet::new();
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_orchestration_missing_node_ref_returns_v018() {
let mut node = make_node("orch.main", NodeType::Orchestration);
node.parallel_groups = Some(vec![ParallelGroup {
group: "phase1".to_owned(),
nodes: vec!["missing.step".to_owned()],
strategy: Strategy::Sequential,
requires: None,
max_concurrency: None,
}]);
let all_ids = HashSet::new(); let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::V018 && e.message.contains("missing.step"))
);
}
#[test]
fn test_validate_orchestration_empty_group_nodes_returns_v018() {
let mut node = make_node("orch.main", NodeType::Orchestration);
node.parallel_groups = Some(vec![ParallelGroup {
group: "phase1".to_owned(),
nodes: vec![], strategy: Strategy::Sequential,
requires: None,
max_concurrency: None,
}]);
let all_ids = HashSet::new();
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V018));
}
#[test]
fn test_validate_orchestration_requires_cycle_returns_v019() {
let mut node = make_node("orch.main", NodeType::Orchestration);
node.parallel_groups = Some(vec![
ParallelGroup {
group: "phase1".to_owned(),
nodes: vec!["step.a".to_owned()],
strategy: Strategy::Sequential,
requires: Some(vec!["phase2".to_owned()]),
max_concurrency: None,
},
ParallelGroup {
group: "phase2".to_owned(),
nodes: vec!["step.b".to_owned()],
strategy: Strategy::Sequential,
requires: Some(vec!["phase1".to_owned()]),
max_concurrency: None,
},
]);
let all_ids = ids(&["step.a", "step.b"]);
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V019));
}
#[test]
fn test_validate_orchestration_requires_nonexistent_group_returns_v018() {
let mut node = make_node("orch.main", NodeType::Orchestration);
node.parallel_groups = Some(vec![ParallelGroup {
group: "phase1".to_owned(),
nodes: vec!["step.a".to_owned()],
strategy: Strategy::Sequential,
requires: Some(vec!["nonexistent.group".to_owned()]),
max_concurrency: None,
}]);
let all_ids = ids(&["step.a"]);
let errors = validate_orchestration(&node, &all_ids, "test.agm");
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::V018 && e.message.contains("nonexistent.group"))
);
}
}