codesynapse-core 0.1.3

Core graph extraction and analysis engine for codesynapse — AST parsing, semantic embeddings, BM25 index
Documentation
use serde_json::Value;

const VALID_FILE_TYPES: &[&str] = &["code", "document", "paper", "image", "rationale", "concept"];
const VALID_CONFIDENCES: &[&str] = &["EXTRACTED", "INFERRED", "AMBIGUOUS"];
const REQUIRED_NODE_FIELDS: &[&str] = &["id", "label", "file_type", "source_file"];
const REQUIRED_EDGE_FIELDS: &[&str] =
    &["source", "target", "relation", "confidence", "source_file"];

pub fn validate_extraction(data: &Value) -> Vec<String> {
    let obj = match data.as_object() {
        Some(o) => o,
        None => return vec!["Extraction must be a JSON object".to_string()],
    };

    let mut errors: Vec<String> = Vec::new();

    let node_ids: std::collections::HashSet<String> = match obj.get("nodes") {
        None => {
            errors.push("Missing required key 'nodes'".to_string());
            std::collections::HashSet::new()
        }
        Some(v) => match v.as_array() {
            None => {
                errors.push("'nodes' must be a list".to_string());
                std::collections::HashSet::new()
            }
            Some(nodes) => {
                let mut ids = std::collections::HashSet::new();
                for (i, node) in nodes.iter().enumerate() {
                    match node.as_object() {
                        None => {
                            errors.push(format!("Node {i} must be an object"));
                            continue;
                        }
                        Some(n) => {
                            let node_id = n.get("id").and_then(|v| v.as_str()).unwrap_or("?");
                            for field in REQUIRED_NODE_FIELDS {
                                if !n.contains_key(*field) {
                                    errors.push(format!(
                                        "Node {i} (id={node_id:?}) missing required field '{field}'"
                                    ));
                                }
                            }
                            if let Some(ft) = n.get("file_type").and_then(|v| v.as_str()) {
                                if !VALID_FILE_TYPES.contains(&ft) {
                                    errors.push(format!(
                                        "Node {i} (id={node_id:?}) has invalid file_type '{ft}' - must be one of {:?}",
                                        {
                                            let mut v: Vec<&&str> = VALID_FILE_TYPES.iter().collect();
                                            v.sort();
                                            v
                                        }
                                    ));
                                }
                            }
                            if let Some(id) = n.get("id").and_then(|v| v.as_str()) {
                                ids.insert(id.to_string());
                            }
                        }
                    }
                }
                ids
            }
        },
    };

    let edge_list = obj.get("edges").or_else(|| obj.get("links"));

    match edge_list {
        None => errors.push("Missing required key 'edges'".to_string()),
        Some(v) => match v.as_array() {
            None => errors.push("'edges' must be a list".to_string()),
            Some(edges) => {
                for (i, edge) in edges.iter().enumerate() {
                    match edge.as_object() {
                        None => {
                            errors.push(format!("Edge {i} must be an object"));
                            continue;
                        }
                        Some(e) => {
                            for field in REQUIRED_EDGE_FIELDS {
                                if !e.contains_key(*field) {
                                    errors
                                        .push(format!("Edge {i} missing required field '{field}'"));
                                }
                            }
                            if let Some(conf) = e.get("confidence").and_then(|v| v.as_str()) {
                                if !VALID_CONFIDENCES.contains(&conf) {
                                    errors.push(format!(
                                        "Edge {i} has invalid confidence '{conf}' - must be one of {:?}",
                                        {
                                            let mut v: Vec<&&str> = VALID_CONFIDENCES.iter().collect();
                                            v.sort();
                                            v
                                        }
                                    ));
                                }
                            }
                            if !node_ids.is_empty() {
                                if let Some(src) = e.get("source").and_then(|v| v.as_str()) {
                                    if !node_ids.contains(src) {
                                        errors.push(format!(
                                            "Edge {i} source '{src}' does not match any node id"
                                        ));
                                    }
                                }
                                if let Some(tgt) = e.get("target").and_then(|v| v.as_str()) {
                                    if !node_ids.contains(tgt) {
                                        errors.push(format!(
                                            "Edge {i} target '{tgt}' does not match any node id"
                                        ));
                                    }
                                }
                            }
                        }
                    }
                }
            }
        },
    }

    errors
}

pub fn assert_valid(data: &Value) -> Result<(), crate::error::CodeSynapseError> {
    let errors = validate_extraction(data);
    if errors.is_empty() {
        Ok(())
    } else {
        let msg = format!(
            "Extraction JSON has {} error(s):\n{}",
            errors.len(),
            errors
                .iter()
                .map(|e| format!("{e}"))
                .collect::<Vec<_>>()
                .join("\n")
        );
        Err(crate::error::CodeSynapseError::Validation(msg))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn valid() -> Value {
        json!({
            "nodes": [
                {"id": "n1", "label": "Foo", "file_type": "code", "source_file": "foo.py"},
                {"id": "n2", "label": "Bar", "file_type": "document", "source_file": "bar.md"},
            ],
            "edges": [
                {"source": "n1", "target": "n2", "relation": "references",
                 "confidence": "EXTRACTED", "source_file": "foo.py", "weight": 1.0},
            ],
        })
    }

    #[test]
    fn test_valid_passes() {
        assert_eq!(validate_extraction(&valid()), Vec::<String>::new());
    }

    #[test]
    fn test_missing_nodes_key() {
        let data = json!({"edges": []});
        let errors = validate_extraction(&data);
        assert!(errors.iter().any(|e| e.contains("nodes")));
    }

    #[test]
    fn test_missing_edges_key() {
        let data = json!({"nodes": []});
        let errors = validate_extraction(&data);
        assert!(errors.iter().any(|e| e.contains("edges")));
    }

    #[test]
    fn test_not_a_dict() {
        let errors = validate_extraction(&json!([]));
        assert_eq!(errors.len(), 1);
    }

    #[test]
    fn test_invalid_file_type() {
        let data = json!({
            "nodes": [{"id": "n1", "label": "X", "file_type": "video", "source_file": "x.mp4"}],
            "edges": [],
        });
        let errors = validate_extraction(&data);
        assert!(errors.iter().any(|e| e.contains("file_type")));
    }

    #[test]
    fn test_invalid_confidence() {
        let data = json!({
            "nodes": [
                {"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
                {"id": "n2", "label": "B", "file_type": "code", "source_file": "b.py"},
            ],
            "edges": [
                {"source": "n1", "target": "n2", "relation": "calls",
                 "confidence": "CERTAIN", "source_file": "a.py"},
            ],
        });
        let errors = validate_extraction(&data);
        assert!(errors.iter().any(|e| e.contains("confidence")));
    }

    #[test]
    fn test_dangling_edge_source() {
        let data = json!({
            "nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
            "edges": [
                {"source": "missing_id", "target": "n1", "relation": "calls",
                 "confidence": "EXTRACTED", "source_file": "a.py"},
            ],
        });
        let errors = validate_extraction(&data);
        assert!(errors
            .iter()
            .any(|e| e.contains("source") && e.contains("missing_id")));
    }

    #[test]
    fn test_dangling_edge_target() {
        let data = json!({
            "nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
            "edges": [
                {"source": "n1", "target": "ghost", "relation": "calls",
                 "confidence": "EXTRACTED", "source_file": "a.py"},
            ],
        });
        let errors = validate_extraction(&data);
        assert!(errors
            .iter()
            .any(|e| e.contains("target") && e.contains("ghost")));
    }

    #[test]
    fn test_missing_node_field() {
        let data = json!({
            "nodes": [{"id": "n1", "label": "A", "source_file": "a.py"}],
            "edges": [],
        });
        let errors = validate_extraction(&data);
        assert!(errors.iter().any(|e| e.contains("file_type")));
    }

    #[test]
    fn test_assert_valid_raises_on_errors() {
        let data = json!({"nodes": "bad", "edges": []});
        assert!(assert_valid(&data).is_err());
        let err = assert_valid(&data).unwrap_err().to_string();
        assert!(err.contains("error"));
    }

    #[test]
    fn test_assert_valid_passes_silently() {
        assert!(assert_valid(&valid()).is_ok());
    }

    #[test]
    fn test_links_fallback() {
        let data = json!({
            "nodes": [
                {"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
            ],
            "links": [
                {"source": "n1", "target": "n1", "relation": "self",
                 "confidence": "INFERRED", "source_file": "a.py"},
            ],
        });
        assert_eq!(validate_extraction(&data), Vec::<String>::new());
    }
}