Skip to main content

agileplus_trace_validator/
lib.rs

1//! AgilePlus trace validator: structural and semantic checks for intent graphs.
2//!
3//! Public entry point is [`validate_intent_graph`], which composes the
4//! individual validator functions across the rule categories. Each category
5//! lives in its own submodule:
6//!
7//! - [`metadata`]: graph-metadata field validation
8//! - [`nodes`]: node completeness + ID format
9//! - [`edges`]: ontology-driven edge constraints
10//! - [`dag`]: acyclicity + DAG flow rules
11//! - [`canonical`]: canonical_map field checks
12//! - [`ontology`]: bundled ontology re-exports
13//!
14//! The public API of the crate (`validate_intent_graph`, `is_valid`,
15//! `first_error`, `validate_node_ids_only`, `ValidationError`,
16//! `ValidationResult`) is preserved by re-exporting each category's public
17//! functions from this module.
18
19use agileplus_domain::intent_graph::*;
20use thiserror::Error;
21
22mod canonical;
23mod dag;
24mod edges;
25mod metadata;
26mod nodes;
27mod ontology;
28pub mod ontology_ext;
29
30pub use canonical::validate_canonical_map;
31pub use dag::{validate_dag_acyclicity, validate_dag_flow};
32pub use edges::validate_edge_constraints;
33pub use metadata::validate_metadata;
34pub use nodes::{validate_node_completeness, validate_node_ids_only};
35pub use ontology::{
36    DagFlow, DagFlowStage, DagValidationRules, EdgeConstraint, EdgeConstraintEntry,
37    EdgeConstraints, Ontology,
38};
39pub use ontology_ext::{
40    extended_relationship_of, validate_extended_edge, validate_extended_edge_constraints,
41    EXTENDED_RELATIONSHIP_PROPERTY,
42};
43
44/// High-level validation error for intent graphs.
45#[derive(Error, Debug, Clone, PartialEq)]
46pub enum ValidationError {
47    #[error("Node ID format error: {0}")]
48    InvalidNodeId(String),
49    #[error("Missing required field: {0}")]
50    MissingRequiredField(String),
51    #[error("Edge constraint violation: {0}")]
52    EdgeConstraintViolation(String),
53    #[error("Cycle detected: {0}")]
54    CycleDetected(String),
55    #[error("DAG flow violation: {0}")]
56    DagFlowViolation(String),
57    #[error("Metadata error: {0}")]
58    MetadataError(String),
59    #[error("Canonical map error: {0}")]
60    CanonicalMapError(String),
61    #[error("Unknown node referenced in edge: {0}")]
62    UnknownNode(String),
63    #[error("Self-loop detected: {0}")]
64    SelfLoop(String),
65}
66
67/// Result type alias for validation.
68pub type ValidationResult = Result<(), Vec<ValidationError>>;
69
70/// Validate an entire intent graph.
71/// Returns `Ok(())` if valid, or a list of `ValidationError` if issues found.
72/// Checks run in order: metadata -> nodes -> edges -> DAG -> flow -> canonical maps.
73pub fn validate_intent_graph(graph: &IntentGraph) -> ValidationResult {
74    let mut errors = Vec::new();
75
76    errors.extend(validate_metadata(&graph.metadata).err().unwrap_or_default());
77    errors.extend(validate_node_completeness(graph).err().unwrap_or_default());
78    errors.extend(validate_edge_constraints(graph).err().unwrap_or_default());
79    errors.extend(validate_dag_acyclicity(graph).err().unwrap_or_default());
80    errors.extend(validate_dag_flow(graph).err().unwrap_or_default());
81    errors.extend(validate_canonical_map(graph).err().unwrap_or_default());
82
83    if errors.is_empty() {
84        Ok(())
85    } else {
86        Err(errors)
87    }
88}
89
90/// Quick validation that returns a boolean. Suitable for backward-compatible use.
91pub fn is_valid(graph: &IntentGraph) -> bool {
92    validate_intent_graph(graph).is_ok()
93}
94
95/// Quick validation that returns only the first error, if any.
96pub fn first_error(graph: &IntentGraph) -> Option<ValidationError> {
97    match validate_intent_graph(graph) {
98        Ok(()) => None,
99        Err(errors) => errors.into_iter().next(),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    #![allow(unused_imports)]
106    use super::*;
107    use agileplus_domain::builder::*;
108    use agileplus_domain::intent_graph::*;
109
110    fn make_valid_graph() -> IntentGraph {
111        let mut builder = IntentGraphBuilder::new().with_default_metadata();
112
113        let intent = NodeBuilder::new()
114            .id("Intent#user-auth")
115            .node_type(NodeType::Intent)
116            .dag_stage(DagStage::Intent)
117            .title("User Authentication")
118            .status(Status::Active)
119            .with_meta("user-prompt")
120            .build()
121            .unwrap();
122
123        let feature = NodeBuilder::new()
124            .id("Feature#oauth2-login")
125            .node_type(NodeType::Feature)
126            .dag_stage(DagStage::Feature)
127            .title("OAuth2 Login")
128            .status(Status::Draft)
129            .with_meta("agent-inference")
130            .build()
131            .unwrap();
132
133        let test = NodeBuilder::new()
134            .id("Test#oauth2-login")
135            .node_type(NodeType::Test)
136            .dag_stage(DagStage::Test)
137            .title("OAuth2 Login Tests")
138            .status(Status::Draft)
139            .with_meta("agent-inference")
140            .build()
141            .unwrap();
142
143        builder = builder
144            .node(intent.clone())
145            .node(feature.clone())
146            .node(test.clone());
147
148        // Intent implements Feature
149        builder = builder.edge(
150            EdgeBuilder::new()
151                .auto_id()
152                .source("Intent#user-auth")
153                .target("Feature#oauth2-login")
154                .relationship_type(RelationshipType::Implements)
155                .with_meta("agent-inference")
156                .build()
157                .unwrap(),
158        );
159
160        // Feature is tested by Test
161        builder = builder.edge(
162            EdgeBuilder::new()
163                .auto_id()
164                .source("Feature#oauth2-login")
165                .target("Test#oauth2-login")
166                .relationship_type(RelationshipType::Tests)
167                .with_meta("agent-inference")
168                .build()
169                .unwrap(),
170        );
171
172        builder.build().unwrap()
173    }
174
175    #[test]
176    fn test_valid_graph() {
177        let graph = make_valid_graph();
178        assert!(validate_intent_graph(&graph).is_ok());
179    }
180
181    #[test]
182    fn test_dag_acyclicity_cycle() {
183        let mut graph = make_valid_graph();
184        let cycle_edge = EdgeBuilder::new()
185            .auto_id()
186            .source("Feature#oauth2-login")
187            .target("Intent#user-auth")
188            .relationship_type(RelationshipType::DerivesFrom)
189            .with_meta("test")
190            .build()
191            .unwrap();
192        graph.add_edge(cycle_edge);
193
194        let result = validate_dag_acyclicity(&graph);
195        assert!(result.is_err());
196        let errs = result.unwrap_err();
197        assert!(errs
198            .iter()
199            .any(|e| matches!(e, ValidationError::CycleDetected(_))));
200    }
201
202    #[test]
203    fn test_edge_constraint_violation() {
204        let mut graph = make_valid_graph();
205        let bad_edge = EdgeBuilder::new()
206            .auto_id()
207            .source("Intent#user-auth")
208            .target("Feature#oauth2-login")
209            .relationship_type(RelationshipType::Tests)
210            .with_meta("test")
211            .build()
212            .unwrap();
213        graph.edges.push(bad_edge);
214
215        let result = validate_edge_constraints(&graph);
216        assert!(result.is_err());
217        let errs = result.unwrap_err();
218        assert!(errs
219            .iter()
220            .any(|e| matches!(e, ValidationError::EdgeConstraintViolation(_))));
221    }
222
223    #[test]
224    fn test_node_completeness_missing_title() {
225        let mut graph = make_valid_graph();
226        graph.nodes[0].title = "".to_string();
227        let result = validate_node_completeness(&graph);
228        assert!(result.is_err());
229    }
230
231    #[test]
232    fn test_metadata_empty_version() {
233        let mut graph = make_valid_graph();
234        graph.metadata.version = "".to_string();
235        let result = validate_metadata(&graph.metadata);
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn test_self_loop() {
241        let mut graph = make_valid_graph();
242        let self_loop = EdgeBuilder::new()
243            .auto_id()
244            .source("Intent#user-auth")
245            .target("Intent#user-auth")
246            .relationship_type(RelationshipType::TracesTo)
247            .with_meta("test")
248            .build()
249            .unwrap();
250        graph.add_edge(self_loop);
251
252        let result = validate_edge_constraints(&graph);
253        assert!(result.is_err());
254        let errs = result.unwrap_err();
255        assert!(errs.iter().any(|e| matches!(e, ValidationError::SelfLoop(_))));
256    }
257
258    #[test]
259    fn test_invalid_node_id() {
260        let mut graph = make_valid_graph();
261        graph.nodes[0].id = NodeId::new("invalid-id".to_string());
262        let result = validate_node_completeness(&graph);
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_dag_flow_root_must_be_intent() {
268        let mut graph = make_valid_graph();
269        graph.nodes.remove(0);
270        graph.edges.clear();
271        let result = validate_dag_flow(&graph);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_backward_compat_is_valid() {
277        let graph = make_valid_graph();
278        assert!(is_valid(&graph));
279        assert!(first_error(&graph).is_none());
280    }
281}