Skip to main content

kaizen/metrics/
codegraph.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Code graph sidecar: SQLite file with the GraphQLite extension (Cypher).
3//! Derived only. Safe to rebuild.
4
5use crate::metrics::types::{RepoEdge, RepoSnapshotRecord, SymbolFact};
6use anyhow::{Context, Result};
7use graphqlite::Connection;
8use std::fs;
9use std::path::Path;
10
11pub fn rebuild_sidecar(
12    graph_path: &Path,
13    snapshot: &RepoSnapshotRecord,
14    files: &[crate::metrics::types::FileFact],
15    symbols: &[SymbolFact],
16    edges: &[RepoEdge],
17) -> Result<()> {
18    if graph_path.exists() {
19        let _ = fs::remove_file(graph_path);
20    }
21    let conn = Connection::open(graph_path).context("open codegraph database")?;
22
23    run_cypher(
24        &conn,
25        &format!(
26            "CREATE (s:Snapshot {{id: '{}', workspace: '{}', head_commit: '{}', analyzer_version: '{}', indexed_at_ms: {}}})",
27            esc(&snapshot.id),
28            esc(&snapshot.workspace),
29            esc(snapshot.head_commit.as_deref().unwrap_or("")),
30            esc(&snapshot.analyzer_version),
31            snapshot.indexed_at_ms
32        ),
33    )?;
34
35    for file in files {
36        run_cypher(
37            &conn,
38            &format!(
39                "CREATE (f:File {{id: '{}', path: '{}', language: '{}', complexity: {}, churn30: {}}})",
40                esc(&file.path),
41                esc(&file.path),
42                esc(&file.language),
43                file.complexity_total,
44                file.churn_30d
45            ),
46        )?;
47        run_cypher(
48            &conn,
49            &format!(
50                "MATCH (s:Snapshot {{id: '{}'}}), (f:File {{id: '{}'}}) CREATE (s)-[:HAS_FILE]->(f)",
51                esc(&snapshot.id),
52                esc(&file.path)
53            ),
54        )?;
55    }
56
57    for symbol in symbols {
58        let sym_id = symbol_id(symbol);
59        run_cypher(
60            &conn,
61            &format!(
62                "CREATE (sym:Symbol {{id: '{}', path: '{}', name: '{}', kind: '{}', complexity: {}}})",
63                esc(&sym_id),
64                esc(&symbol.path),
65                esc(&symbol.name),
66                esc(&symbol.kind),
67                symbol.complexity
68            ),
69        )?;
70        run_cypher(
71            &conn,
72            &format!(
73                "MATCH (fb:File {{id: '{}'}}), (sm:Symbol {{id: '{}'}}) CREATE (fb)-[:DECLARES]->(sm)",
74                esc(&symbol.path),
75                esc(&sym_id)
76            ),
77        )?;
78    }
79
80    for edge in edges {
81        if edge.kind == "CALLS" {
82            run_cypher(
83                &conn,
84                &format!(
85                    "MATCH (a:Symbol {{id: '{}'}}), (b:Symbol {{id: '{}'}}) CREATE (a)-[:CALLS {{weight: {}}}]->(b)",
86                    esc(&edge.from_path),
87                    esc(&edge.to_path),
88                    edge.weight
89                ),
90            )?;
91            continue;
92        }
93        run_cypher(
94            &conn,
95            &format!(
96                "MATCH (a:File {{id: '{}'}}), (b:File {{id: '{}'}}) CREATE (a)-[:{} {{weight: {}}}]->(b)",
97                esc(&edge.from_path),
98                esc(&edge.to_path),
99                edge.kind,
100                edge.weight
101            ),
102        )?;
103    }
104
105    Ok(())
106}
107
108fn run_cypher(conn: &Connection, query: &str) -> Result<()> {
109    conn.cypher(query)
110        .map(|_| ())
111        .map_err(|e| anyhow::anyhow!("{e}"))
112}
113
114pub fn symbol_id(symbol: &SymbolFact) -> String {
115    format!(
116        "{}#{}:{}-{}",
117        symbol.path, symbol.name, symbol.start_byte, symbol.end_byte
118    )
119}
120
121fn esc(input: &str) -> String {
122    input.replace('\\', "\\\\").replace('\'', "\\'")
123}