sara_core/validation/
validator.rs

1//! Main validation orchestrator.
2
3use std::time::Instant;
4
5use crate::config::ValidationConfig;
6use crate::graph::KnowledgeGraph;
7use crate::validation::report::{ValidationReport, ValidationReportBuilder};
8use crate::validation::rules;
9
10/// Orchestrates all validation rules.
11pub struct Validator {
12    /// Configuration for validation behavior.
13    config: ValidationConfig,
14}
15
16impl Validator {
17    /// Creates a new validator with the given configuration.
18    pub fn new(config: ValidationConfig) -> Self {
19        Self { config }
20    }
21
22    /// Creates a validator with default configuration.
23    pub fn with_defaults() -> Self {
24        Self::new(ValidationConfig::default())
25    }
26
27    /// Validates the knowledge graph and returns a report.
28    pub fn validate(&self, graph: &KnowledgeGraph) -> ValidationReport {
29        let start = Instant::now();
30        let mut builder = ValidationReportBuilder::new()
31            .items_checked(graph.item_count())
32            .relationships_checked(graph.relationship_count());
33
34        // Run all validation rules
35
36        // 1. Check for broken references (FR-010)
37        let broken_refs = rules::check_broken_references(graph);
38        builder = builder.errors(broken_refs);
39
40        // 2. Check for orphan items (FR-011)
41        // Orphans are errors in strict mode, warnings otherwise
42        let orphans = rules::check_orphans(graph, self.config.strict_orphans);
43        if self.config.strict_orphans {
44            builder = builder.errors(orphans);
45        } else {
46            builder = builder.warnings(orphans);
47        }
48
49        // 3. Check for duplicate identifiers (FR-012)
50        // Note: Duplicates are typically caught during parsing/graph construction
51        let duplicates = rules::check_duplicates(graph);
52        builder = builder.errors(duplicates);
53
54        // 4. Check for circular references (FR-013)
55        let cycles = rules::check_cycles(graph);
56        builder = builder.errors(cycles);
57
58        // 5. Check metadata validity (FR-014)
59        let metadata_errors = rules::check_metadata(graph, &self.config.allowed_custom_fields);
60        builder = builder.errors(metadata_errors);
61
62        // 6. Check relationship validity (FR-006, FR-007, FR-008)
63        let relationship_errors = rules::check_relationships(graph);
64        builder = builder.errors(relationship_errors);
65
66        // 7. Check for redundant relationships (warning only)
67        let redundant = rules::check_redundant_relationships(graph);
68        builder = builder.warnings(redundant);
69
70        builder.duration(start.elapsed()).build()
71    }
72}
73
74impl Default for Validator {
75    fn default() -> Self {
76        Self::with_defaults()
77    }
78}
79
80/// Convenience function to validate a graph with default settings.
81pub fn validate(graph: &KnowledgeGraph) -> ValidationReport {
82    Validator::with_defaults().validate(graph)
83}
84
85/// Convenience function to validate a graph with strict orphan checking.
86pub fn validate_strict(graph: &KnowledgeGraph) -> ValidationReport {
87    let config = ValidationConfig {
88        strict_orphans: true,
89        ..Default::default()
90    };
91    Validator::new(config).validate(graph)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::graph::GraphBuilder;
98    use crate::model::{
99        ItemBuilder, ItemId, ItemType, RelationshipType, SourceLocation, UpstreamRefs,
100    };
101    use std::path::PathBuf;
102
103    fn create_item(
104        id: &str,
105        item_type: ItemType,
106        upstream: Option<UpstreamRefs>,
107    ) -> crate::model::Item {
108        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
109        let mut builder = ItemBuilder::new()
110            .id(ItemId::new_unchecked(id))
111            .item_type(item_type)
112            .name(format!("Test {}", id))
113            .source(source);
114
115        if let Some(up) = upstream {
116            builder = builder.upstream(up);
117        }
118
119        if item_type.requires_specification() {
120            builder = builder.specification("Test spec");
121        }
122
123        builder.build().unwrap()
124    }
125
126    #[test]
127    fn test_valid_graph() {
128        let graph = GraphBuilder::new()
129            .add_item(create_item("SOL-001", ItemType::Solution, None))
130            .add_item(create_item(
131                "UC-001",
132                ItemType::UseCase,
133                Some(UpstreamRefs {
134                    refines: vec![ItemId::new_unchecked("SOL-001")],
135                    ..Default::default()
136                }),
137            ))
138            .build()
139            .unwrap();
140
141        let report = validate(&graph);
142        assert!(report.is_valid(), "Valid graph should pass validation");
143        assert_eq!(report.error_count(), 0);
144    }
145
146    #[test]
147    fn test_broken_reference() {
148        let graph = GraphBuilder::new()
149            .add_item(create_item(
150                "UC-001",
151                ItemType::UseCase,
152                Some(UpstreamRefs {
153                    refines: vec![ItemId::new_unchecked("SOL-MISSING")],
154                    ..Default::default()
155                }),
156            ))
157            .build()
158            .unwrap();
159
160        let report = validate(&graph);
161        assert!(!report.is_valid());
162        assert!(report.error_count() > 0);
163    }
164
165    #[test]
166    fn test_orphan_warning() {
167        let graph = GraphBuilder::new()
168            .add_item(create_item("UC-001", ItemType::UseCase, None))
169            .build()
170            .unwrap();
171
172        // Non-strict mode: orphan is a warning
173        let report = validate(&graph);
174        assert!(
175            report.is_valid(),
176            "Orphan should be warning in non-strict mode"
177        );
178        assert_eq!(report.warning_count(), 1);
179    }
180
181    #[test]
182    fn test_orphan_error_strict() {
183        let graph = GraphBuilder::new()
184            .add_item(create_item("UC-001", ItemType::UseCase, None))
185            .build()
186            .unwrap();
187
188        // Strict mode: orphan is an error
189        let report = validate_strict(&graph);
190        assert!(!report.is_valid(), "Orphan should be error in strict mode");
191        assert_eq!(report.error_count(), 1);
192    }
193
194    #[test]
195    fn test_cycle_detection() {
196        let mut graph = KnowledgeGraph::new(false);
197
198        // Create a cycle
199        let scen1 = create_item(
200            "SCEN-001",
201            ItemType::Scenario,
202            Some(UpstreamRefs {
203                refines: vec![ItemId::new_unchecked("SCEN-002")],
204                ..Default::default()
205            }),
206        );
207        let scen2 = create_item(
208            "SCEN-002",
209            ItemType::Scenario,
210            Some(UpstreamRefs {
211                refines: vec![ItemId::new_unchecked("SCEN-001")],
212                ..Default::default()
213            }),
214        );
215
216        graph.add_item(scen1);
217        graph.add_item(scen2);
218
219        graph.add_relationship(
220            &ItemId::new_unchecked("SCEN-001"),
221            &ItemId::new_unchecked("SCEN-002"),
222            RelationshipType::Refines,
223        );
224        graph.add_relationship(
225            &ItemId::new_unchecked("SCEN-002"),
226            &ItemId::new_unchecked("SCEN-001"),
227            RelationshipType::Refines,
228        );
229
230        let report = validate(&graph);
231        assert!(!report.is_valid(), "Cycle should be detected");
232    }
233
234    #[test]
235    fn test_invalid_relationship() {
236        let mut graph = KnowledgeGraph::new(false);
237
238        // Scenario trying to refine Solution directly (invalid)
239        graph.add_item(create_item("SOL-001", ItemType::Solution, None));
240        graph.add_item(create_item(
241            "SCEN-001",
242            ItemType::Scenario,
243            Some(UpstreamRefs {
244                refines: vec![ItemId::new_unchecked("SOL-001")],
245                ..Default::default()
246            }),
247        ));
248
249        let report = validate(&graph);
250        assert!(
251            !report.is_valid(),
252            "Invalid relationship should be detected"
253        );
254    }
255}