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}