Skip to main content

codesynapse_core/
validate.rs

1use serde_json::Value;
2
3const VALID_FILE_TYPES: &[&str] = &["code", "document", "paper", "image", "rationale", "concept"];
4const VALID_CONFIDENCES: &[&str] = &["EXTRACTED", "INFERRED", "AMBIGUOUS"];
5const REQUIRED_NODE_FIELDS: &[&str] = &["id", "label", "file_type", "source_file"];
6const REQUIRED_EDGE_FIELDS: &[&str] =
7    &["source", "target", "relation", "confidence", "source_file"];
8
9pub fn validate_extraction(data: &Value) -> Vec<String> {
10    let obj = match data.as_object() {
11        Some(o) => o,
12        None => return vec!["Extraction must be a JSON object".to_string()],
13    };
14
15    let mut errors: Vec<String> = Vec::new();
16
17    let node_ids: std::collections::HashSet<String> = match obj.get("nodes") {
18        None => {
19            errors.push("Missing required key 'nodes'".to_string());
20            std::collections::HashSet::new()
21        }
22        Some(v) => match v.as_array() {
23            None => {
24                errors.push("'nodes' must be a list".to_string());
25                std::collections::HashSet::new()
26            }
27            Some(nodes) => {
28                let mut ids = std::collections::HashSet::new();
29                for (i, node) in nodes.iter().enumerate() {
30                    match node.as_object() {
31                        None => {
32                            errors.push(format!("Node {i} must be an object"));
33                            continue;
34                        }
35                        Some(n) => {
36                            let node_id = n.get("id").and_then(|v| v.as_str()).unwrap_or("?");
37                            for field in REQUIRED_NODE_FIELDS {
38                                if !n.contains_key(*field) {
39                                    errors.push(format!(
40                                        "Node {i} (id={node_id:?}) missing required field '{field}'"
41                                    ));
42                                }
43                            }
44                            if let Some(ft) = n.get("file_type").and_then(|v| v.as_str()) {
45                                if !VALID_FILE_TYPES.contains(&ft) {
46                                    errors.push(format!(
47                                        "Node {i} (id={node_id:?}) has invalid file_type '{ft}' - must be one of {:?}",
48                                        {
49                                            let mut v: Vec<&&str> = VALID_FILE_TYPES.iter().collect();
50                                            v.sort();
51                                            v
52                                        }
53                                    ));
54                                }
55                            }
56                            if let Some(id) = n.get("id").and_then(|v| v.as_str()) {
57                                ids.insert(id.to_string());
58                            }
59                        }
60                    }
61                }
62                ids
63            }
64        },
65    };
66
67    let edge_list = obj.get("edges").or_else(|| obj.get("links"));
68
69    match edge_list {
70        None => errors.push("Missing required key 'edges'".to_string()),
71        Some(v) => match v.as_array() {
72            None => errors.push("'edges' must be a list".to_string()),
73            Some(edges) => {
74                for (i, edge) in edges.iter().enumerate() {
75                    match edge.as_object() {
76                        None => {
77                            errors.push(format!("Edge {i} must be an object"));
78                            continue;
79                        }
80                        Some(e) => {
81                            for field in REQUIRED_EDGE_FIELDS {
82                                if !e.contains_key(*field) {
83                                    errors
84                                        .push(format!("Edge {i} missing required field '{field}'"));
85                                }
86                            }
87                            if let Some(conf) = e.get("confidence").and_then(|v| v.as_str()) {
88                                if !VALID_CONFIDENCES.contains(&conf) {
89                                    errors.push(format!(
90                                        "Edge {i} has invalid confidence '{conf}' - must be one of {:?}",
91                                        {
92                                            let mut v: Vec<&&str> = VALID_CONFIDENCES.iter().collect();
93                                            v.sort();
94                                            v
95                                        }
96                                    ));
97                                }
98                            }
99                            if !node_ids.is_empty() {
100                                if let Some(src) = e.get("source").and_then(|v| v.as_str()) {
101                                    if !node_ids.contains(src) {
102                                        errors.push(format!(
103                                            "Edge {i} source '{src}' does not match any node id"
104                                        ));
105                                    }
106                                }
107                                if let Some(tgt) = e.get("target").and_then(|v| v.as_str()) {
108                                    if !node_ids.contains(tgt) {
109                                        errors.push(format!(
110                                            "Edge {i} target '{tgt}' does not match any node id"
111                                        ));
112                                    }
113                                }
114                            }
115                        }
116                    }
117                }
118            }
119        },
120    }
121
122    errors
123}
124
125pub fn assert_valid(data: &Value) -> Result<(), crate::error::CodeSynapseError> {
126    let errors = validate_extraction(data);
127    if errors.is_empty() {
128        Ok(())
129    } else {
130        let msg = format!(
131            "Extraction JSON has {} error(s):\n{}",
132            errors.len(),
133            errors
134                .iter()
135                .map(|e| format!("  • {e}"))
136                .collect::<Vec<_>>()
137                .join("\n")
138        );
139        Err(crate::error::CodeSynapseError::Validation(msg))
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use serde_json::json;
147
148    fn valid() -> Value {
149        json!({
150            "nodes": [
151                {"id": "n1", "label": "Foo", "file_type": "code", "source_file": "foo.py"},
152                {"id": "n2", "label": "Bar", "file_type": "document", "source_file": "bar.md"},
153            ],
154            "edges": [
155                {"source": "n1", "target": "n2", "relation": "references",
156                 "confidence": "EXTRACTED", "source_file": "foo.py", "weight": 1.0},
157            ],
158        })
159    }
160
161    #[test]
162    fn test_valid_passes() {
163        assert_eq!(validate_extraction(&valid()), Vec::<String>::new());
164    }
165
166    #[test]
167    fn test_missing_nodes_key() {
168        let data = json!({"edges": []});
169        let errors = validate_extraction(&data);
170        assert!(errors.iter().any(|e| e.contains("nodes")));
171    }
172
173    #[test]
174    fn test_missing_edges_key() {
175        let data = json!({"nodes": []});
176        let errors = validate_extraction(&data);
177        assert!(errors.iter().any(|e| e.contains("edges")));
178    }
179
180    #[test]
181    fn test_not_a_dict() {
182        let errors = validate_extraction(&json!([]));
183        assert_eq!(errors.len(), 1);
184    }
185
186    #[test]
187    fn test_invalid_file_type() {
188        let data = json!({
189            "nodes": [{"id": "n1", "label": "X", "file_type": "video", "source_file": "x.mp4"}],
190            "edges": [],
191        });
192        let errors = validate_extraction(&data);
193        assert!(errors.iter().any(|e| e.contains("file_type")));
194    }
195
196    #[test]
197    fn test_invalid_confidence() {
198        let data = json!({
199            "nodes": [
200                {"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
201                {"id": "n2", "label": "B", "file_type": "code", "source_file": "b.py"},
202            ],
203            "edges": [
204                {"source": "n1", "target": "n2", "relation": "calls",
205                 "confidence": "CERTAIN", "source_file": "a.py"},
206            ],
207        });
208        let errors = validate_extraction(&data);
209        assert!(errors.iter().any(|e| e.contains("confidence")));
210    }
211
212    #[test]
213    fn test_dangling_edge_source() {
214        let data = json!({
215            "nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
216            "edges": [
217                {"source": "missing_id", "target": "n1", "relation": "calls",
218                 "confidence": "EXTRACTED", "source_file": "a.py"},
219            ],
220        });
221        let errors = validate_extraction(&data);
222        assert!(errors
223            .iter()
224            .any(|e| e.contains("source") && e.contains("missing_id")));
225    }
226
227    #[test]
228    fn test_dangling_edge_target() {
229        let data = json!({
230            "nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
231            "edges": [
232                {"source": "n1", "target": "ghost", "relation": "calls",
233                 "confidence": "EXTRACTED", "source_file": "a.py"},
234            ],
235        });
236        let errors = validate_extraction(&data);
237        assert!(errors
238            .iter()
239            .any(|e| e.contains("target") && e.contains("ghost")));
240    }
241
242    #[test]
243    fn test_missing_node_field() {
244        let data = json!({
245            "nodes": [{"id": "n1", "label": "A", "source_file": "a.py"}],
246            "edges": [],
247        });
248        let errors = validate_extraction(&data);
249        assert!(errors.iter().any(|e| e.contains("file_type")));
250    }
251
252    #[test]
253    fn test_assert_valid_raises_on_errors() {
254        let data = json!({"nodes": "bad", "edges": []});
255        assert!(assert_valid(&data).is_err());
256        let err = assert_valid(&data).unwrap_err().to_string();
257        assert!(err.contains("error"));
258    }
259
260    #[test]
261    fn test_assert_valid_passes_silently() {
262        assert!(assert_valid(&valid()).is_ok());
263    }
264
265    #[test]
266    fn test_links_fallback() {
267        let data = json!({
268            "nodes": [
269                {"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
270            ],
271            "links": [
272                {"source": "n1", "target": "n1", "relation": "self",
273                 "confidence": "INFERRED", "source_file": "a.py"},
274            ],
275        });
276        assert_eq!(validate_extraction(&data), Vec::<String>::new());
277    }
278}