polaris_graph/graph/validation.rs
1//! Graph validation logic and error types.
2
3use super::Graph;
4use crate::edge::{Edge, EdgeId};
5use crate::node::{Node, NodeId};
6use hashbrown::{HashMap, HashSet};
7use polaris_system::param::ERROR_CONTEXT;
8use std::any::TypeId;
9use std::fmt;
10
11/// Context tag for timeout path validation.
12///
13/// Used by the graph validator to check that systems requiring timeout
14/// context are wired behind a timeout edge.
15const TIMEOUT_CONTEXT: &str = "timeout";
16
17impl Graph {
18 // ─────────────────────────────────────────────────────────────────────────
19 // Validation API
20 // ─────────────────────────────────────────────────────────────────────────
21
22 /// Validates the graph structure for correctness.
23 ///
24 /// This method performs build-time validation to catch errors before execution:
25 /// - Verifies the graph has an entry point
26 /// - Checks all edges reference valid nodes
27 /// - Ensures decision nodes have predicates and both branches
28 /// - Ensures loop nodes have termination conditions
29 /// - Ensures parallel nodes have branches (subgraphs)
30 /// - Ensures switch nodes have discriminators
31 /// - Warns if parallel branches produce overlapping output types
32 /// - Errors if a loop predicate reads an output type no body system produces
33 /// - Ensures edge requirements are met (e.g. error edges target nodes that can fail)
34 ///
35 /// # Returns
36 ///
37 /// A [`ValidationResult`] containing both errors and warnings. Use
38 /// [`ValidationResult::is_ok()`] to check if the graph is structurally valid.
39 ///
40 /// # Example
41 ///
42 /// ```
43 /// # use polaris_graph::Graph;
44 /// # async fn my_system() -> i32 { 1 }
45 /// let mut graph = Graph::new();
46 /// graph.add_system(my_system);
47 ///
48 ///
49 /// let result = graph.validate();
50 /// for w in &result.warnings {
51 /// tracing::warn!(%w, "graph validation warning");
52 /// }
53 /// if !result.is_ok() {
54 /// for err in &result.errors {
55 /// tracing::error!(%err, "graph validation error");
56 /// }
57 /// }
58 /// ```
59 pub fn validate(&self) -> ValidationResult {
60 let mut errors = Vec::new();
61 let mut warnings = Vec::new();
62
63 // Check for entry point
64 if self.entry.is_none() {
65 errors.push(ValidationError::NoEntryPoint);
66 }
67
68 // Build a set of valid node IDs for quick lookup
69 let valid_nodes: HashSet<NodeId> = self.nodes.iter().map(Node::id).collect();
70
71 // Validate entry point exists
72 if let Some(entry) = &self.entry
73 && !valid_nodes.contains(entry)
74 {
75 errors.push(ValidationError::InvalidEntryPoint(entry.clone()));
76 }
77
78 // Validate edges reference valid nodes and build edge-target index
79 // for edge requirement validation.
80 let mut error_edge_targets: HashSet<NodeId> = HashSet::new();
81 let mut timeout_edge_targets: HashSet<NodeId> = HashSet::new();
82
83 for edge in &self.edges {
84 self.validate_edge(edge, &valid_nodes, &mut errors);
85
86 match edge {
87 Edge::Error(err_edge) => {
88 error_edge_targets.insert(err_edge.to.clone());
89 }
90 Edge::Timeout(timeout_edge) => {
91 timeout_edge_targets.insert(timeout_edge.to.clone());
92 }
93 _ => {}
94 }
95 }
96
97 // Validate each node
98 for node in &self.nodes {
99 self.validate_node(
100 node,
101 &valid_nodes,
102 &error_edge_targets,
103 &timeout_edge_targets,
104 &mut errors,
105 &mut warnings,
106 );
107 }
108
109 ValidationResult { errors, warnings }
110 }
111
112 /// Validates a single edge.
113 ///
114 /// # Edge Type Validation
115 ///
116 /// ## Sequential Edge (A -> B)
117 /// - `from` must reference an existing node
118 /// - `to` must reference an existing node
119 ///
120 /// ## Conditional Edge (A -> B if true, A -> C if false)
121 /// - `from` must reference an existing node (typically a `DecisionNode`)
122 /// - `true_target` must reference an existing node
123 /// - `false_target` must reference an existing node
124 ///
125 /// ## Parallel Edge (A -> [B, C, D])
126 /// - `from` must reference an existing node (typically a `ParallelNode`)
127 /// - All `targets` must reference existing nodes
128 ///
129 /// ## `LoopBack` Edge (end -> start)
130 /// - `from` must reference an existing node (end of loop body)
131 /// - `to` must reference an existing node (loop entry point)
132 ///
133 /// ## Error Edge (A -> handler on failure)
134 /// - `from` must reference an existing node (the node that may fail)
135 /// - `to` must reference an existing node (error handler)
136 ///
137 /// ## Timeout Edge (A -> handler on timeout)
138 /// - `from` must reference an existing node (the node with timeout)
139 /// - `to` must reference an existing node (timeout handler)
140 fn validate_edge(
141 &self,
142 edge: &Edge,
143 valid_nodes: &HashSet<NodeId>,
144 errors: &mut Vec<ValidationError>,
145 ) {
146 match edge {
147 // Sequential: simple A -> B connection
148 // Both source and target must exist
149 Edge::Sequential(seq) => {
150 if !valid_nodes.contains(&seq.from) {
151 errors.push(ValidationError::InvalidEdgeSource {
152 edge: seq.id.clone(),
153 node: seq.from.clone(),
154 });
155 }
156 if !valid_nodes.contains(&seq.to) {
157 errors.push(ValidationError::InvalidEdgeTarget {
158 edge: seq.id.clone(),
159 node: seq.to.clone(),
160 });
161 }
162 }
163 // Conditional: binary branch with true/false targets
164 // Source and both targets must exist
165 Edge::Conditional(cond) => {
166 if !valid_nodes.contains(&cond.from) {
167 errors.push(ValidationError::InvalidEdgeSource {
168 edge: cond.id.clone(),
169 node: cond.from.clone(),
170 });
171 }
172 if !valid_nodes.contains(&cond.true_target) {
173 errors.push(ValidationError::InvalidEdgeTarget {
174 edge: cond.id.clone(),
175 node: cond.true_target.clone(),
176 });
177 }
178 if !valid_nodes.contains(&cond.false_target) {
179 errors.push(ValidationError::InvalidEdgeTarget {
180 edge: cond.id.clone(),
181 node: cond.false_target.clone(),
182 });
183 }
184 }
185 // Parallel: fork to multiple targets
186 // Source and all targets must exist
187 Edge::Parallel(par) => {
188 if !valid_nodes.contains(&par.from) {
189 errors.push(ValidationError::InvalidEdgeSource {
190 edge: par.id.clone(),
191 node: par.from.clone(),
192 });
193 }
194 for target in &par.targets {
195 if !valid_nodes.contains(target) {
196 errors.push(ValidationError::InvalidEdgeTarget {
197 edge: par.id.clone(),
198 node: target.clone(),
199 });
200 }
201 }
202 }
203 // LoopBack: return to earlier node for iteration
204 // Both source (loop body end) and target (loop entry) must exist
205 Edge::LoopBack(lb) => {
206 if !valid_nodes.contains(&lb.from) {
207 errors.push(ValidationError::InvalidEdgeSource {
208 edge: lb.id.clone(),
209 node: lb.from.clone(),
210 });
211 }
212 if !valid_nodes.contains(&lb.to) {
213 errors.push(ValidationError::InvalidEdgeTarget {
214 edge: lb.id.clone(),
215 node: lb.to.clone(),
216 });
217 }
218 }
219 // Error: fallback path when a system fails
220 // Both the failing node and error handler must exist
221 Edge::Error(err) => {
222 if !valid_nodes.contains(&err.from) {
223 errors.push(ValidationError::InvalidEdgeSource {
224 edge: err.id.clone(),
225 node: err.from.clone(),
226 });
227 }
228 if !valid_nodes.contains(&err.to) {
229 errors.push(ValidationError::InvalidEdgeTarget {
230 edge: err.id.clone(),
231 node: err.to.clone(),
232 });
233 }
234 }
235 // Timeout: fallback path when a system times out
236 // Both the timed-out node and timeout handler must exist
237 Edge::Timeout(timeout) => {
238 if !valid_nodes.contains(&timeout.from) {
239 errors.push(ValidationError::InvalidEdgeSource {
240 edge: timeout.id.clone(),
241 node: timeout.from.clone(),
242 });
243 }
244 if !valid_nodes.contains(&timeout.to) {
245 errors.push(ValidationError::InvalidEdgeTarget {
246 edge: timeout.id.clone(),
247 node: timeout.to.clone(),
248 });
249 }
250 }
251 }
252 }
253
254 /// Validates a single node.
255 ///
256 /// # Node Type Validation
257 ///
258 /// ## `SystemNode`
259 /// - Must meet edge requirements for any attached edges
260 /// e.g. if an error edge targets this node, it must be able to fail,
261 /// and if a timeout edge targets this node, it must have a timeout set.
262 ///
263 /// ## `DecisionNode`
264 /// - Must have a predicate function
265 /// - Must have both `true_branch` and `false_branch` targets
266 /// - Branch targets must reference existing nodes
267 ///
268 /// ## `SwitchNode`
269 /// - Must have a discriminator function
270 /// - Must have at least one case or a default
271 /// - All case targets must reference existing nodes
272 /// - Default target (if present) must reference an existing node
273 ///
274 /// ## `ParallelNode`
275 /// - Must have at least one branch
276 /// - All branch targets must reference existing nodes
277 ///
278 /// ## `LoopNode`
279 /// - Must have either a termination predicate or `max_iterations`
280 /// - Must have a body entry point
281 /// - Body entry must reference an existing node
282 fn validate_node(
283 &self,
284 node: &Node,
285 valid_nodes: &HashSet<NodeId>,
286 error_edge_targets: &HashSet<NodeId>,
287 timeout_edge_targets: &HashSet<NodeId>,
288 errors: &mut Vec<ValidationError>,
289 warnings: &mut Vec<ValidationWarning>,
290 ) {
291 match node {
292 // System nodes: check context requirements against actual edge wiring
293 Node::System(sys) => {
294 let access = sys.system.access();
295 for &tag in &access.context_requirements {
296 let satisfied = match tag {
297 ERROR_CONTEXT => error_edge_targets.contains(&sys.id),
298 TIMEOUT_CONTEXT => timeout_edge_targets.contains(&sys.id),
299 _ => true, // unknown tags are not validated here
300 };
301 if !satisfied {
302 errors.push(ValidationError::MissingEdgeRequirement {
303 node: sys.id.clone(),
304 name: sys.system.name(),
305 requirement: tag,
306 });
307 }
308 }
309 }
310
311 // Decision nodes need a predicate and both branch targets
312 Node::Decision(dec) => {
313 if dec.predicate.is_none() {
314 errors.push(ValidationError::MissingPredicate {
315 node: dec.id.clone(),
316 name: dec.name,
317 });
318 }
319 if dec.true_branch.is_none() {
320 errors.push(ValidationError::MissingBranch {
321 node: dec.id.clone(),
322 name: dec.name,
323 branch: "true",
324 });
325 } else if let Some(target) = &dec.true_branch
326 && !valid_nodes.contains(target)
327 {
328 errors.push(ValidationError::InvalidBranchTarget {
329 node: dec.id.clone(),
330 branch: "true",
331 target: target.clone(),
332 });
333 }
334 if dec.false_branch.is_none() {
335 errors.push(ValidationError::MissingBranch {
336 node: dec.id.clone(),
337 name: dec.name,
338 branch: "false",
339 });
340 } else if let Some(target) = &dec.false_branch
341 && !valid_nodes.contains(target)
342 {
343 errors.push(ValidationError::InvalidBranchTarget {
344 node: dec.id.clone(),
345 branch: "false",
346 target: target.clone(),
347 });
348 }
349 }
350
351 // Switch nodes need a discriminator and at least one case or default
352 Node::Switch(sw) => {
353 if sw.discriminator.is_none() {
354 errors.push(ValidationError::MissingDiscriminator {
355 node: sw.id.clone(),
356 name: sw.name,
357 });
358 }
359 if sw.cases.is_empty() && sw.default.is_none() {
360 errors.push(ValidationError::EmptySwitch {
361 node: sw.id.clone(),
362 name: sw.name,
363 });
364 }
365 for (case_name, target) in &sw.cases {
366 if !valid_nodes.contains(target) {
367 errors.push(ValidationError::InvalidCaseTarget {
368 node: sw.id.clone(),
369 case: case_name,
370 target: target.clone(),
371 });
372 }
373 }
374 if let Some(default) = &sw.default
375 && !valid_nodes.contains(default)
376 {
377 errors.push(ValidationError::InvalidDefaultTarget {
378 node: sw.id.clone(),
379 target: default.clone(),
380 });
381 }
382 }
383
384 // Parallel nodes need branches
385 Node::Parallel(par) => {
386 if par.branches.is_empty() {
387 errors.push(ValidationError::EmptyParallel {
388 node: par.id.clone(),
389 name: par.name,
390 });
391 }
392 for branch in &par.branches {
393 if !valid_nodes.contains(branch) {
394 errors.push(ValidationError::InvalidBranchTarget {
395 node: par.id.clone(),
396 branch: "parallel",
397 target: branch.clone(),
398 });
399 }
400 }
401
402 // Check for overlapping output types across parallel branches.
403 // If 2+ branches produce the same type, the last branch in
404 // declaration order silently wins at merge time.
405 let mut type_counts: HashMap<TypeId, (usize, &'static str)> = HashMap::new();
406 for branch in &par.branches {
407 let branch_types: HashSet<_> = self
408 .collect_branch_output_types(branch)
409 .into_iter()
410 .collect();
411 for (type_id, type_name) in branch_types {
412 type_counts
413 .entry(type_id)
414 .and_modify(|(count, _)| *count += 1)
415 .or_insert((1, type_name));
416 }
417 }
418 for (_, (count, type_name)) in type_counts {
419 if count > 1 {
420 warnings.push(ValidationWarning::ConflictingParallelOutputs {
421 node: par.id.clone(),
422 name: par.name,
423 output_type: type_name,
424 });
425 }
426 }
427 }
428
429 // Loop nodes need a termination condition and a body
430 Node::Loop(lp) => {
431 // Must have either termination predicate or max_iterations to prevent infinite loops
432 if lp.termination.is_none() && lp.max_iterations.is_none() {
433 errors.push(ValidationError::NoTerminationCondition {
434 node: lp.id.clone(),
435 name: lp.name,
436 });
437 }
438 if lp.body_entry.is_none() {
439 errors.push(ValidationError::EmptyLoopBody {
440 node: lp.id.clone(),
441 name: lp.name,
442 });
443 } else if let Some(body) = &lp.body_entry
444 && !valid_nodes.contains(body)
445 {
446 errors.push(ValidationError::InvalidLoopBody {
447 node: lp.id.clone(),
448 target: body.clone(),
449 });
450 }
451
452 // If a termination predicate exists and the body is valid,
453 // check that the predicate's input type is actually produced
454 // by a system in the loop body.
455 if let Some(term) = &lp.termination
456 && let Some(body) = &lp.body_entry
457 && valid_nodes.contains(body)
458 {
459 let predicate_input = term.input_type_id();
460 let body_output_types: HashSet<TypeId> = self
461 .collect_branch_output_types(body)
462 .into_iter()
463 .map(|(id, _)| id)
464 .collect();
465 if !body_output_types.contains(&predicate_input) {
466 errors.push(ValidationError::LoopPredicateOutputNotProduced {
467 node: lp.id.clone(),
468 name: lp.name,
469 expected_output: term.input_type_name(),
470 });
471 }
472 }
473 }
474
475 // Scope nodes: validate the embedded graph recursively
476 Node::Scope(scope) => {
477 if scope.graph.is_empty() {
478 errors.push(ValidationError::EmptyScopeGraph {
479 node: scope.id.clone(),
480 name: scope.name,
481 });
482 } else {
483 if scope.graph.entry().is_none() {
484 errors.push(ValidationError::ScopeGraphNoEntryPoint {
485 node: scope.id.clone(),
486 name: scope.name,
487 });
488 }
489 let inner_result = scope.graph.validate();
490 for inner_err in inner_result.errors {
491 errors.push(ValidationError::ScopeGraphInvalid {
492 node: scope.id.clone(),
493 name: scope.name,
494 inner: Box::new(inner_err),
495 });
496 }
497 for inner_warn in inner_result.warnings {
498 warnings.push(ValidationWarning::ScopeGraphWarning {
499 node: scope.id.clone(),
500 name: scope.name,
501 inner: Box::new(inner_warn),
502 });
503 }
504 }
505 }
506 }
507 }
508}
509
510/// Result of graph validation, containing both errors and warnings.
511#[derive(Debug, Clone, Default)]
512pub struct ValidationResult {
513 /// Structural errors that prevent execution.
514 pub errors: Vec<ValidationError>,
515 /// Non-fatal issues that may cause unexpected runtime behavior.
516 pub warnings: Vec<ValidationWarning>,
517}
518
519impl ValidationResult {
520 /// Returns true if no structural errors were found.
521 #[must_use]
522 pub fn is_ok(&self) -> bool {
523 self.errors.is_empty()
524 }
525
526 /// Returns true if structural errors were found.
527 #[must_use]
528 pub fn is_err(&self) -> bool {
529 !self.is_ok()
530 }
531
532 /// Returns true if warnings were found.
533 #[must_use]
534 pub fn has_warnings(&self) -> bool {
535 !self.warnings.is_empty()
536 }
537
538 /// Return a vector of warnings.
539 #[must_use]
540 pub fn warnings(&self) -> &[ValidationWarning] {
541 &self.warnings
542 }
543
544 /// Return a vector of errors.
545 #[must_use]
546 pub fn errors(&self) -> &[ValidationError] {
547 &self.errors
548 }
549}
550
551impl fmt::Display for ValidationResult {
552 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553 if self.is_ok() {
554 write!(
555 f,
556 "validation passed with 0 error(s) and {} warning(s)",
557 self.warnings.len()
558 )?;
559 } else {
560 write!(
561 f,
562 "validation failed with {} error(s) and {} warning(s)",
563 self.errors.len(),
564 self.warnings.len()
565 )?;
566 }
567
568 for err in &self.errors {
569 write!(f, "\n error: {err}")?;
570 }
571 for warn in &self.warnings {
572 write!(f, "\n warning: {warn}")?;
573 }
574 Ok(())
575 }
576}
577
578/// Warnings produced during graph validation.
579///
580/// Warnings indicate potential issues that won't prevent execution but may
581/// cause unexpected behavior at runtime.
582#[derive(Debug, Clone)]
583#[non_exhaustive]
584pub enum ValidationWarning {
585 /// Two or more parallel branches produce the same output type.
586 /// The last branch in declaration order will win at merge time.
587 ConflictingParallelOutputs {
588 /// The parallel node ID.
589 node: NodeId,
590 /// The parallel node name.
591 name: &'static str,
592 /// The conflicting output type name.
593 output_type: &'static str,
594 },
595 /// A scope node's embedded graph has a validation warning.
596 ScopeGraphWarning {
597 /// The scope node ID.
598 node: NodeId,
599 /// The scope node name.
600 name: &'static str,
601 /// The inner validation warning from the embedded graph.
602 inner: Box<ValidationWarning>,
603 },
604}
605
606impl fmt::Display for ValidationWarning {
607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608 match self {
609 ValidationWarning::ConflictingParallelOutputs {
610 name,
611 node,
612 output_type,
613 } => {
614 write!(
615 f,
616 "parallel node '{name}' ({node}) has multiple branches producing output type '{output_type}'; last branch wins"
617 )
618 }
619 ValidationWarning::ScopeGraphWarning { node, name, inner } => {
620 write!(
621 f,
622 "scope node '{name}' ({node}) embedded graph warning: {inner}"
623 )
624 }
625 }
626 }
627}
628
629impl std::error::Error for ValidationWarning {}
630
631/// Errors that can occur during graph validation.
632///
633/// These errors are detected at build time (when calling [`Graph::validate`])
634/// before the graph is executed, allowing early detection of structural issues.
635#[derive(Debug, Clone)]
636#[non_exhaustive]
637pub enum ValidationError {
638 /// The graph has no entry point.
639 NoEntryPoint,
640 /// The entry point references an invalid node.
641 InvalidEntryPoint(NodeId),
642 /// An edge's source node doesn't exist.
643 InvalidEdgeSource {
644 /// The edge ID.
645 edge: EdgeId,
646 /// The invalid node ID.
647 node: NodeId,
648 },
649 /// An edge's target node doesn't exist.
650 InvalidEdgeTarget {
651 /// The edge ID.
652 edge: EdgeId,
653 /// The invalid node ID.
654 node: NodeId,
655 },
656 /// A decision node is missing its predicate.
657 MissingPredicate {
658 /// The node ID.
659 node: NodeId,
660 /// The node name.
661 name: &'static str,
662 },
663 /// A decision node is missing a branch target.
664 MissingBranch {
665 /// The node ID.
666 node: NodeId,
667 /// The node name.
668 name: &'static str,
669 /// Which branch is missing ("true" or "false").
670 branch: &'static str,
671 },
672 /// A branch target references an invalid node.
673 InvalidBranchTarget {
674 /// The node ID.
675 node: NodeId,
676 /// The branch name.
677 branch: &'static str,
678 /// The invalid target node ID.
679 target: NodeId,
680 },
681 /// A switch node is missing its discriminator.
682 MissingDiscriminator {
683 /// The node ID.
684 node: NodeId,
685 /// The node name.
686 name: &'static str,
687 },
688 /// A switch node has no cases and no default.
689 EmptySwitch {
690 /// The node ID.
691 node: NodeId,
692 /// The node name.
693 name: &'static str,
694 },
695 /// A switch case target references an invalid node.
696 InvalidCaseTarget {
697 /// The node ID.
698 node: NodeId,
699 /// The case name.
700 case: &'static str,
701 /// The invalid target node ID.
702 target: NodeId,
703 },
704 /// A switch default target references an invalid node.
705 InvalidDefaultTarget {
706 /// The node ID.
707 node: NodeId,
708 /// The invalid target node ID.
709 target: NodeId,
710 },
711 /// A parallel node has no branches.
712 EmptyParallel {
713 /// The node ID.
714 node: NodeId,
715 /// The node name.
716 name: &'static str,
717 },
718 /// A loop node has no termination condition.
719 NoTerminationCondition {
720 /// The node ID.
721 node: NodeId,
722 /// The node name.
723 name: &'static str,
724 },
725 /// A loop node has no body.
726 EmptyLoopBody {
727 /// The node ID.
728 node: NodeId,
729 /// The node name.
730 name: &'static str,
731 },
732 /// A loop body entry references an invalid node.
733 InvalidLoopBody {
734 /// The node ID.
735 node: NodeId,
736 /// The invalid target node ID.
737 target: NodeId,
738 },
739 /// A loop's termination predicate reads an output type that no system in
740 /// the loop body produces.
741 LoopPredicateOutputNotProduced {
742 /// The loop node ID.
743 node: NodeId,
744 /// The loop node name.
745 name: &'static str,
746 /// The output type the predicate expects.
747 expected_output: &'static str,
748 },
749 /// A system requires a specific edge type but is not reachable via that edge.
750 ///
751 /// For example, a system using `CaughtError` must be the target of an
752 /// error edge; placing it on a normal sequential path is a wiring mistake.
753 MissingEdgeRequirement {
754 /// The node ID.
755 node: NodeId,
756 /// The system name.
757 name: &'static str,
758 /// A human-readable description of the required edge type.
759 requirement: &'static str,
760 },
761 /// A scope node contains an empty graph.
762 EmptyScopeGraph {
763 /// The scope node ID.
764 node: NodeId,
765 /// The scope node name.
766 name: &'static str,
767 },
768 /// A scope node's embedded graph has no entry point.
769 ScopeGraphNoEntryPoint {
770 /// The scope node ID.
771 node: NodeId,
772 /// The scope node name.
773 name: &'static str,
774 },
775 /// A scope node's embedded graph has a validation error.
776 ScopeGraphInvalid {
777 /// The scope node ID.
778 node: NodeId,
779 /// The scope node name.
780 name: &'static str,
781 /// The inner validation error from the embedded graph.
782 inner: Box<ValidationError>,
783 },
784}
785
786impl fmt::Display for ValidationError {
787 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
788 match self {
789 ValidationError::NoEntryPoint => write!(f, "graph has no entry point"),
790 ValidationError::InvalidEntryPoint(id) => {
791 write!(f, "entry point references invalid node: {id}")
792 }
793 ValidationError::InvalidEdgeSource { edge, node } => {
794 write!(f, "edge {edge} has invalid source node: {node}")
795 }
796 ValidationError::InvalidEdgeTarget { edge, node } => {
797 write!(f, "edge {edge} has invalid target node: {node}")
798 }
799 ValidationError::MissingPredicate { node, name } => {
800 write!(f, "decision node '{name}' ({node}) is missing predicate")
801 }
802 ValidationError::MissingBranch { node, name, branch } => {
803 write!(
804 f,
805 "decision node '{name}' ({node}) is missing {branch} branch"
806 )
807 }
808 ValidationError::InvalidBranchTarget {
809 node,
810 branch,
811 target,
812 } => {
813 write!(
814 f,
815 "node {node} has {branch} branch pointing to invalid node: {target}"
816 )
817 }
818 ValidationError::MissingDiscriminator { node, name } => {
819 write!(f, "switch node '{name}' ({node}) is missing discriminator")
820 }
821 ValidationError::EmptySwitch { node, name } => {
822 write!(
823 f,
824 "switch node '{name}' ({node}) has no cases and no default"
825 )
826 }
827 ValidationError::InvalidCaseTarget { node, case, target } => {
828 write!(
829 f,
830 "switch node {node} has case '{case}' pointing to invalid node: {target}"
831 )
832 }
833 ValidationError::InvalidDefaultTarget { node, target } => {
834 write!(
835 f,
836 "switch node {node} has default pointing to invalid node: {target}"
837 )
838 }
839 ValidationError::EmptyParallel { node, name } => {
840 write!(f, "parallel node '{name}' ({node}) has no branches")
841 }
842 ValidationError::NoTerminationCondition { node, name } => {
843 write!(
844 f,
845 "loop node '{name}' ({node}) has no termination condition (predicate or max_iterations)"
846 )
847 }
848 ValidationError::EmptyLoopBody { node, name } => {
849 write!(f, "loop node '{name}' ({node}) has no body")
850 }
851 ValidationError::InvalidLoopBody { node, target } => {
852 write!(
853 f,
854 "loop node {node} has body entry pointing to invalid node: {target}"
855 )
856 }
857 ValidationError::LoopPredicateOutputNotProduced {
858 node,
859 name,
860 expected_output,
861 } => {
862 write!(
863 f,
864 "loop node '{name}' ({node}) predicate expects output type '{expected_output}' not produced by any system in the loop body"
865 )
866 }
867 ValidationError::MissingEdgeRequirement {
868 node,
869 name,
870 requirement,
871 } => {
872 write!(
873 f,
874 "system '{name}' ({node}) requires {requirement} edge context but is not reachable via a matching edge"
875 )
876 }
877 ValidationError::EmptyScopeGraph { node, name } => {
878 write!(f, "scope node '{name}' ({node}) contains an empty graph")
879 }
880 ValidationError::ScopeGraphNoEntryPoint { node, name } => {
881 write!(
882 f,
883 "scope node '{name}' ({node}) embedded graph has no entry point"
884 )
885 }
886 ValidationError::ScopeGraphInvalid { node, name, inner } => {
887 write!(
888 f,
889 "scope node '{name}' ({node}) embedded graph error: {inner}"
890 )
891 }
892 }
893 }
894}
895
896impl std::error::Error for ValidationError {}
897
898/// Errors that can occur when appending one graph to another via
899/// [`Graph::append`].
900#[derive(Debug, Clone)]
901pub enum MergeError {
902 /// A graph has no entry point.
903 NoEntry,
904 /// A graph has no exit point (last node).
905 NoExit,
906 /// A graph contains nodes not reachable from its entry point.
907 DisconnectedNodes {
908 /// Number of unreachable nodes.
909 orphan_count: usize,
910 /// Orphan node IDs (up to 3 for error message).
911 orphans: Vec<NodeId>,
912 },
913}
914
915impl fmt::Display for MergeError {
916 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
917 match self {
918 MergeError::NoEntry => write!(f, "graph has no entry point"),
919 MergeError::NoExit => write!(f, "graph has no exit point (last node)"),
920 MergeError::DisconnectedNodes {
921 orphan_count,
922 orphans,
923 } => {
924 write!(f, "graph has {orphan_count} node(s) unreachable from entry")?;
925 if *orphan_count > 0 {
926 write!(f, ": ")?;
927 for (i, orphan) in orphans.iter().take(3).enumerate() {
928 write!(f, "{orphan}")?;
929 if i < orphans.len().min(3) - 1 {
930 write!(f, ", ")?;
931 }
932 }
933 if *orphan_count > 3 {
934 write!(f, ", ...")?;
935 }
936 }
937 Ok(())
938 }
939 }
940 }
941}
942
943impl std::error::Error for MergeError {}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::node::{ContextPolicy, Node, SystemNode};
949 use polaris_system::system::IntoSystem;
950
951 async fn dummy() -> i32 {
952 1
953 }
954
955 #[test]
956 fn scope_graph_no_entry_point() {
957 // Construct a Graph with nodes but no entry point.
958 // This can only happen through pub(crate) access since all public
959 // builder methods set the entry on the first node.
960 let mut inner = Graph::default();
961 let node = Node::System(SystemNode::new(dummy.into_system()));
962 inner.nodes.push(node);
963 // entry remains None, but is_empty() is false
964
965 let mut graph = Graph::new();
966 graph.add_scope("no_entry_scope", inner, ContextPolicy::shared());
967
968 let result = graph.validate();
969 assert!(
970 result
971 .errors
972 .iter()
973 .any(|err| matches!(err, ValidationError::ScopeGraphNoEntryPoint { .. })),
974 "expected ScopeGraphNoEntryPoint error, got: {:?}",
975 result.errors
976 );
977 }
978}