use crate::diagnostics::{categories, Diagnostic, ValidationReport};
use crate::model::{DependencyGraph, PipelineContract};
pub fn validate(contract: &PipelineContract) -> ValidationReport {
let mut report = ValidationReport::new();
let step_ids = contract.step_ids();
for (index, point) in contract.graph.entry_points.iter().enumerate() {
if point.trim().is_empty() {
continue;
}
if !step_ids.contains(point.as_str()) {
report.push(
Diagnostic::error(
"DPCS-GRP-007",
categories::GRAPH,
format!("graph entry point references unknown step `{point}`"),
)
.with_object_ref(format!("graph.entryPoints[{index}]"))
.with_remediation(
"Declare graph.entryPoints that refer to declared step identifiers",
),
);
}
}
for (index, point) in contract.graph.exit_points.iter().enumerate() {
if point.trim().is_empty() {
continue;
}
if !step_ids.contains(point.as_str()) {
report.push(
Diagnostic::error(
"DPCS-GRP-008",
categories::GRAPH,
format!("graph exit point references unknown step `{point}`"),
)
.with_object_ref(format!("graph.exitPoints[{index}]"))
.with_remediation(
"Declare graph.exitPoints that refer to declared step identifiers",
),
);
}
}
for (index, edge) in contract.graph.edges.iter().enumerate() {
let object_ref = format!("graph.edges[{index}]");
if edge.from.trim().is_empty() || edge.to.trim().is_empty() {
report.push(
Diagnostic::error(
"DPCS-GRP-001",
categories::GRAPH,
"graph edge endpoints must not be empty",
)
.with_object_ref(object_ref.clone()),
);
continue;
}
if !step_ids.contains(edge.from.as_str()) {
report.push(
Diagnostic::error(
"DPCS-GRP-002",
categories::GRAPH,
format!("graph edge references unknown step `{}`", edge.from),
)
.with_object_ref(object_ref.clone())
.with_remediation("Ensure edge endpoints refer to declared step identifiers"),
);
}
if !step_ids.contains(edge.to.as_str()) {
report.push(
Diagnostic::error(
"DPCS-GRP-003",
categories::GRAPH,
format!("graph edge references unknown step `{}`", edge.to),
)
.with_object_ref(object_ref)
.with_remediation("Ensure edge endpoints refer to declared step identifiers"),
);
}
}
for duplicate in DependencyGraph::duplicate_edges(contract) {
let kind_label = duplicate
.kind
.as_deref()
.map(|kind| format!(" (kind `{kind}`)"))
.unwrap_or_else(|| " (untyped)".to_string());
report.push(
Diagnostic::error(
"DPCS-GRP-005",
categories::GRAPH,
format!(
"duplicate graph edge from `{}` to `{}`{kind_label}",
duplicate.from, duplicate.to
),
)
.with_object_ref(format!("graph.edges[{}]", duplicate.duplicate_index))
.with_remediation("Remove duplicate edges from graph.edges"),
);
}
let dependency_graph = DependencyGraph::from_contract(contract);
let cycle = dependency_graph.find_cycle();
let has_cycle = cycle.is_some();
if let Some(cycle) = cycle {
let cycle_node = cycle.first().cloned().unwrap_or_default();
report.push(
Diagnostic::error(
"DPCS-GRP-004",
categories::GRAPH,
format!("pipeline graph contains a prohibited cycle involving `{cycle_node}`"),
)
.with_object_ref("graph")
.with_remediation(
"Remove cyclic dependencies from graph.edges, controlFlow, or dataFlow",
),
);
}
if !has_cycle {
let has_declared_entry_points = contract
.graph
.entry_points
.iter()
.any(|id| !id.trim().is_empty());
let unreachable = dependency_graph.unreachable_steps(contract);
for step_id in unreachable {
let message = if has_declared_entry_points {
format!("pipeline step `{step_id}` is unreachable from graph entry points")
} else {
format!("pipeline step `{step_id}` is unreachable from graph roots")
};
report.push(
Diagnostic::error("DPCS-GRP-006", categories::GRAPH, message)
.with_object_ref(format!("steps.{step_id}"))
.with_remediation(
"Declare graph.entryPoints or add edges so every step is reachable",
),
);
}
}
report
}