Skip to main content

infigraph_core/graph/
store.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5use fs2::FileExt;
6use kuzu::{Connection, Database, SystemConfig};
7
8use super::schema::{CREATE_SCHEMA, MIGRATIONS};
9use super::store_util::escape;
10
11/// RAII guard for exclusive write access to the graph store.
12/// Holds an advisory file lock on `<db_path>.lock`.
13pub struct WriteLock {
14    _file: std::fs::File,
15}
16
17impl WriteLock {
18    fn acquire(lock_path: &Path) -> Result<Self> {
19        if let Some(parent) = lock_path.parent() {
20            std::fs::create_dir_all(parent)?;
21        }
22        let file = std::fs::OpenOptions::new()
23            .create(true)
24            .write(true)
25            .truncate(false)
26            .open(lock_path)?;
27        file.lock_exclusive()
28            .map_err(|e| anyhow::anyhow!("failed to acquire write lock: {e}"))?;
29        Ok(Self { _file: file })
30    }
31
32    fn try_acquire(lock_path: &Path) -> Result<Option<Self>> {
33        if let Some(parent) = lock_path.parent() {
34            std::fs::create_dir_all(parent)?;
35        }
36        let file = std::fs::OpenOptions::new()
37            .create(true)
38            .write(true)
39            .truncate(false)
40            .open(lock_path)?;
41        match file.try_lock_exclusive() {
42            Ok(()) => Ok(Some(Self { _file: file })),
43            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
44            Err(e) => Err(anyhow::anyhow!("lock error: {e}")),
45        }
46    }
47}
48
49/// Persistent graph store backed by Kuzu.
50pub struct GraphStore {
51    db: Database,
52    lock_path: PathBuf,
53}
54
55impl GraphStore {
56    /// Open or create a Kuzu database at the given path.
57    pub fn open(path: &Path) -> Result<Self> {
58        if let Some(parent) = path.parent() {
59            std::fs::create_dir_all(parent)?;
60        }
61        let lock_path = path.with_extension("lock");
62        let db = Database::new(path, SystemConfig::default())
63            .map_err(|e| anyhow::anyhow!("failed to open kuzu db: {e}"))?;
64        let store = Self { db, lock_path };
65        store.init_schema()?;
66        Ok(store)
67    }
68
69    /// Acquire exclusive write lock. Blocks until available.
70    pub fn write_lock(&self) -> Result<WriteLock> {
71        WriteLock::acquire(&self.lock_path)
72    }
73
74    /// Try to acquire write lock without blocking. Returns None if already held.
75    pub fn try_write_lock(&self) -> Result<Option<WriteLock>> {
76        WriteLock::try_acquire(&self.lock_path)
77    }
78
79    fn init_schema(&self) -> Result<()> {
80        let conn = self.connection()?;
81        for ddl in CREATE_SCHEMA {
82            conn.query(ddl)
83                .map_err(|e| anyhow::anyhow!("schema error: {e}\n  DDL: {ddl}"))?;
84        }
85        for migration in MIGRATIONS {
86            let _ = conn.query(migration);
87        }
88        Ok(())
89    }
90
91    pub fn connection(&self) -> Result<Connection<'_>> {
92        Connection::new(&self.db).map_err(|e| anyhow::anyhow!("failed to create connection: {e}"))
93    }
94
95    /// Remove all graph data for a deleted file.
96    pub fn remove_file(&self, file: &str) -> Result<()> {
97        let _lock = self.write_lock()?;
98        let conn = self.connection()?;
99        self.remove_file_conn(&conn, file)
100    }
101
102    /// Caller must hold WriteLock.
103    pub fn remove_file_conn(&self, conn: &Connection<'_>, file: &str) -> Result<()> {
104        let _ = conn.query(&format!(
105            "MATCH (f:File)-[:DEFINES]->(s:Symbol)-[:HAS_STATEMENT]->(st:Statement) WHERE f.id = '{}' DETACH DELETE st",
106            escape(file)
107        ));
108        let _ = conn.query(&format!(
109            "MATCH (s:Symbol) WHERE s.file = '{}' DETACH DELETE s",
110            escape(file)
111        ));
112        let _ = conn.query(&format!(
113            "MATCH (m:Module) WHERE m.file = '{}' DETACH DELETE m",
114            escape(file)
115        ));
116        let _ = conn.query(&format!(
117            "MATCH (f:File) WHERE f.id = '{}' DETACH DELETE f",
118            escape(file)
119        ));
120        Ok(())
121    }
122
123    /// Return map of file path -> content_hash for all indexed modules.
124    /// Used by incremental indexing to skip unchanged files.
125    pub fn get_file_hashes(&self) -> Result<HashMap<String, String>> {
126        let conn = self.connection()?;
127        let result = conn
128            .query("MATCH (m:Module) RETURN m.file, m.content_hash")
129            .map_err(|e| anyhow::anyhow!("get_file_hashes failed: {e}"))?;
130        let mut map = HashMap::new();
131        for row in result {
132            if row.len() >= 2 {
133                map.insert(row[0].to_string(), row[1].to_string());
134            }
135        }
136        Ok(map)
137    }
138
139    /// Return all symbols as (name, id, file, kind) tuples -- used by resolve_calls.
140    pub fn get_all_symbols(&self) -> Result<Vec<(String, String, String, String)>> {
141        let conn = self.connection()?;
142        let result = conn
143            .query("MATCH (s:Symbol) RETURN s.name, s.id, s.file, s.kind")
144            .map_err(|e| anyhow::anyhow!("get_all_symbols failed: {e}"))?;
145        let mut symbols = Vec::new();
146        for row in result {
147            if row.len() >= 4 {
148                symbols.push((
149                    row[0].to_string(),
150                    row[1].to_string(),
151                    row[2].to_string(),
152                    row[3].to_string(),
153                ));
154            }
155        }
156        Ok(symbols)
157    }
158
159    /// Get total counts for stats.
160    pub fn derive_tested_by_edges(&self) -> Result<usize> {
161        let _lock = self.write_lock()?;
162        let conn = self.connection()?;
163        let q = super::queries::GraphQuery::new(&conn);
164        q.derive_tested_by_edges()
165    }
166
167    pub fn stats(&self) -> Result<GraphStats> {
168        let conn = self.connection()?;
169
170        let symbol_count = count_query(&conn, "MATCH (s:Symbol) RETURN count(s)")?;
171        let module_count = count_query(&conn, "MATCH (m:Module) RETURN count(m)")?;
172        let file_count = count_query(&conn, "MATCH (f:File) RETURN count(f)")?;
173        let folder_count = count_query(&conn, "MATCH (d:Folder) RETURN count(d)")?;
174        let calls_count = count_query(&conn, "MATCH ()-[r:CALLS]->() RETURN count(r)")?;
175        let inherits_count = count_query(&conn, "MATCH ()-[r:INHERITS]->() RETURN count(r)")?;
176        let contains_count = count_query(&conn, "MATCH ()-[r:CONTAINS]->() RETURN count(r)")?;
177
178        Ok(GraphStats {
179            symbols: symbol_count,
180            modules: module_count,
181            files: file_count,
182            folders: folder_count,
183            calls: calls_count,
184            inherits: inherits_count,
185            contains: contains_count,
186        })
187    }
188}
189
190#[derive(Debug)]
191pub struct GraphStats {
192    pub symbols: u64,
193    pub modules: u64,
194    pub files: u64,
195    pub folders: u64,
196    pub calls: u64,
197    pub inherits: u64,
198    pub contains: u64,
199}
200
201impl std::fmt::Display for GraphStats {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        writeln!(f, "Graph Statistics:")?;
204        writeln!(f, "  Symbols:      {}", self.symbols)?;
205        writeln!(f, "  Modules:      {}", self.modules)?;
206        writeln!(f, "  Files:        {}", self.files)?;
207        writeln!(f, "  Folders:      {}", self.folders)?;
208        writeln!(f, "  Calls edges:  {}", self.calls)?;
209        writeln!(f, "  Inherits:     {}", self.inherits)?;
210        writeln!(f, "  Contains:     {}", self.contains)
211    }
212}
213
214fn count_query(conn: &Connection, query: &str) -> Result<u64> {
215    let mut result = conn
216        .query(query)
217        .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
218    if let Some(row) = result.next() {
219        if let Some(val) = row.first() {
220            return Ok(val.to_string().parse().unwrap_or(0));
221        }
222    }
223    Ok(0)
224}