Skip to main content

graphify_export/
cypher.rs

1//! Neo4j Cypher export.
2
3use std::fmt::Write;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use graphify_core::graph::KnowledgeGraph;
8use tracing::info;
9
10/// Export the graph as Cypher CREATE statements for Neo4j.
11pub fn export_cypher(graph: &KnowledgeGraph, output_dir: &Path) -> anyhow::Result<PathBuf> {
12    let mut cypher = String::with_capacity(4096);
13
14    // Nodes
15    for node in graph.nodes() {
16        let node_type_label = format!("{:?}", node.node_type);
17        write!(
18            cypher,
19            "CREATE (n{}:{} {{id: '{}', label: '{}', source_file: '{}'",
20            sanitize_var(&node.id),
21            node_type_label,
22            cypher_escape(&node.id),
23            cypher_escape(&node.label),
24            cypher_escape(&node.source_file),
25        )
26        .unwrap();
27        if let Some(loc) = &node.source_location {
28            write!(cypher, ", source_location: '{}'", cypher_escape(loc)).unwrap();
29        }
30        if let Some(c) = node.community {
31            write!(cypher, ", community: {}", c).unwrap();
32        }
33        writeln!(cypher, "}});").unwrap();
34    }
35
36    writeln!(cypher).unwrap();
37
38    // Edges
39    for edge in graph.edges() {
40        let rel_type = edge
41            .relation
42            .to_uppercase()
43            .replace(|c: char| !c.is_alphanumeric() && c != '_', "_");
44        writeln!(
45            cypher,
46            "CREATE (n{})-[:{}  {{relation: '{}', confidence: '{:?}', confidence_score: {:.2}, source_file: '{}', weight: {:.2}}}]->(n{});",
47            sanitize_var(&edge.source),
48            rel_type,
49            cypher_escape(&edge.relation),
50            edge.confidence,
51            edge.confidence_score,
52            cypher_escape(&edge.source_file),
53            edge.weight,
54            sanitize_var(&edge.target),
55        )
56        .unwrap();
57    }
58
59    fs::create_dir_all(output_dir)?;
60    let path = output_dir.join("graph.cypher");
61    fs::write(&path, &cypher)?;
62    info!(path = %path.display(), "exported Cypher statements");
63    Ok(path)
64}
65
66/// Make a valid Cypher variable name from a node ID.
67fn sanitize_var(id: &str) -> String {
68    let mut out = String::with_capacity(id.len());
69    for c in id.chars() {
70        if c.is_alphanumeric() || c == '_' {
71            out.push(c);
72        } else {
73            out.push('_');
74        }
75    }
76    // Ensure it doesn't start with a digit
77    if out.starts_with(|c: char| c.is_ascii_digit()) {
78        out.insert(0, '_');
79    }
80    out
81}
82
83fn cypher_escape(s: &str) -> String {
84    s.replace('\\', "\\\\").replace('\'', "\\'")
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use graphify_core::confidence::Confidence;
91    use graphify_core::graph::KnowledgeGraph;
92    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
93    use std::collections::HashMap;
94
95    fn sample_graph() -> KnowledgeGraph {
96        let mut kg = KnowledgeGraph::new();
97        kg.add_node(GraphNode {
98            id: "my_class".into(),
99            label: "MyClass".into(),
100            source_file: "src/main.rs".into(),
101            source_location: Some("L42".into()),
102            node_type: NodeType::Class,
103            community: Some(0),
104            extra: HashMap::new(),
105        })
106        .unwrap();
107        kg.add_node(GraphNode {
108            id: "helper".into(),
109            label: "Helper".into(),
110            source_file: "src/util.rs".into(),
111            source_location: None,
112            node_type: NodeType::Function,
113            community: None,
114            extra: HashMap::new(),
115        })
116        .unwrap();
117        kg.add_edge(GraphEdge {
118            source: "my_class".into(),
119            target: "helper".into(),
120            relation: "calls".into(),
121            confidence: Confidence::Extracted,
122            confidence_score: 1.0,
123            source_file: "src/main.rs".into(),
124            source_location: None,
125            weight: 1.0,
126            extra: HashMap::new(),
127        })
128        .unwrap();
129        kg
130    }
131
132    #[test]
133    fn export_cypher_creates_file() {
134        let dir = tempfile::tempdir().unwrap();
135        let kg = sample_graph();
136        let path = export_cypher(&kg, dir.path()).unwrap();
137        assert!(path.exists());
138
139        let content = std::fs::read_to_string(&path).unwrap();
140        assert!(content.contains("CREATE (n"));
141        assert!(content.contains("CALLS"));
142        assert!(content.contains("MyClass"));
143    }
144
145    #[test]
146    fn sanitize_var_removes_special_chars() {
147        assert_eq!(sanitize_var("my-class.foo"), "my_class_foo");
148        assert_eq!(sanitize_var("123abc"), "_123abc");
149    }
150
151    #[test]
152    fn cypher_escape_quotes() {
153        assert_eq!(cypher_escape("it's"), "it\\'s");
154    }
155}