Skip to main content

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}