1use crate::config::Context;
8use crate::models::{CallRelation, ImportRelation, Symbol};
9use gobby_core::degradation::ServiceState;
10use gobby_core::falkor::GraphClient;
11use serde_json::Value;
12use std::collections::{BTreeSet, HashSet};
13
14use super::GraphReadError;
15use super::connection::with_required_core_graph;
16
17mod deletion;
18mod mutation;
19mod support;
20mod sync_plan;
21
22pub(crate) use deletion::{
23 cleanup_orphans_queries, clear_all_code_index_query, clear_project_query,
24 count_file_projection_nodes_query, delete_file_graph_queries, delete_file_node_query,
25 project_file_path_queries,
26};
27pub use mutation::call_target_id;
28pub(in crate::graph::code_graph) use mutation::{import_graph_items, partition_call_graph_items};
29
30use deletion::delete_stale_file_graph_queries;
31use mutation::{
32 SyncFileMutation, add_definitions_query, add_external_calls_query, add_imports_query,
33 add_symbol_calls_query, add_unresolved_calls_query, definition_graph_symbols,
34 ensure_file_node_query, new_sync_token,
35};
36use support::execute_write_query;
37use sync_plan::plan_sync_batches;
38
39const PROJECT_INDEXED_LABELS: &[&str] = &[
40 "CodeFile",
41 "CodeSymbol",
42 "CodeModule",
43 "UnresolvedCallee",
44 "ExternalSymbol",
45];
46
47pub struct CodeGraph<'a> {
48 project_id: &'a str,
49 client: &'a mut GraphClient,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GraphOrphanCleanup {
54 pub stale_files_deleted: usize,
55 pub graph_nodes_deleted: usize,
56}
57
58impl<'a> CodeGraph<'a> {
59 pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
60 Self { project_id, client }
61 }
62
63 pub fn sync_file(
64 &mut self,
65 file_path: &str,
66 imports: &[ImportRelation],
67 definitions: &[Symbol],
68 calls: &[CallRelation],
69 cleanup_orphans: bool,
70 ) -> anyhow::Result<usize> {
71 let sync_token = new_sync_token(file_path);
72 let import_items = import_graph_items(file_path, imports);
73 let symbols = definition_graph_symbols(definitions);
74 let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
75 let relationship_count = import_items.len()
76 + symbols.len()
77 + call_groups.symbol.len()
78 + call_groups.external.len()
79 + call_groups.unresolved.len();
80 for query in plan_sync_batches(SyncFileMutation {
83 project_id: self.project_id,
84 file_path,
85 symbol_count: definitions.len(),
86 imports: &import_items,
87 symbols: &symbols,
88 calls: &call_groups,
89 sync_token: &sync_token,
90 })? {
91 execute_write_query(self.client, query)?;
92 }
93 self.delete_stale_file_graph(file_path, &sync_token)?;
97 if cleanup_orphans {
98 self.cleanup_orphans()?;
99 }
100 Ok(relationship_count)
101 }
102
103 pub fn ensure_project_indexes(&mut self) -> anyhow::Result<()> {
104 for label in PROJECT_INDEXED_LABELS {
105 self.client.ensure_exact_node_index(label, "project")?;
106 }
107 Ok(())
108 }
109
110 pub fn ensure_file_node(
111 &mut self,
112 file_path: &str,
113 symbol_count: usize,
114 sync_token: &str,
115 ) -> anyhow::Result<()> {
116 execute_write_query(
117 self.client,
118 ensure_file_node_query(self.project_id, file_path, symbol_count, sync_token)?,
119 )
120 }
121
122 pub fn add_imports(
123 &mut self,
124 file_path: &str,
125 imports: &[ImportRelation],
126 sync_token: &str,
127 ) -> anyhow::Result<usize> {
128 let items = import_graph_items(file_path, imports);
129 if items.is_empty() {
130 return Ok(0);
131 }
132 let written = items.len();
133 execute_write_query(
134 self.client,
135 add_imports_query(self.project_id, &items, sync_token)?,
136 )?;
137 Ok(written)
138 }
139
140 pub fn add_definitions(
141 &mut self,
142 file_path: &str,
143 definitions: &[Symbol],
144 sync_token: &str,
145 ) -> anyhow::Result<usize> {
146 let symbols = definitions
147 .iter()
148 .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
149 .collect::<Vec<_>>();
150 if symbols.is_empty() {
151 return Ok(0);
152 }
153 let written = symbols.len();
154 execute_write_query(
155 self.client,
156 add_definitions_query(self.project_id, file_path, &symbols, sync_token)?,
157 )?;
158 Ok(written)
159 }
160
161 pub fn add_calls(
162 &mut self,
163 file_path: &str,
164 calls: &[CallRelation],
165 sync_token: &str,
166 ) -> anyhow::Result<usize> {
167 let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
168
169 let mut written = 0;
170 if !call_groups.symbol.is_empty() {
171 written += call_groups.symbol.len();
172 execute_write_query(
173 self.client,
174 add_symbol_calls_query(self.project_id, &call_groups.symbol, sync_token)?,
175 )?;
176 }
177 if !call_groups.external.is_empty() {
178 written += call_groups.external.len();
179 execute_write_query(
180 self.client,
181 add_external_calls_query(self.project_id, &call_groups.external, sync_token)?,
182 )?;
183 }
184 if !call_groups.unresolved.is_empty() {
185 written += call_groups.unresolved.len();
186 execute_write_query(
187 self.client,
188 add_unresolved_calls_query(self.project_id, &call_groups.unresolved, sync_token)?,
189 )?;
190 }
191 Ok(written)
192 }
193
194 pub fn delete_stale_file_graph(
195 &mut self,
196 file_path: &str,
197 sync_token: &str,
198 ) -> anyhow::Result<()> {
199 for query in delete_stale_file_graph_queries(self.project_id, file_path, sync_token)? {
200 execute_write_query(self.client, query)?;
201 }
202 Ok(())
203 }
204
205 pub fn delete_file_graph(
206 &mut self,
207 file_path: &str,
208 current_symbol_ids: &[String],
209 ) -> anyhow::Result<()> {
210 for query in delete_file_graph_queries(self.project_id, file_path, current_symbol_ids)? {
211 execute_write_query(self.client, query)?;
212 }
213 Ok(())
214 }
215
216 pub fn delete_file_node(&mut self, file_path: &str) -> anyhow::Result<()> {
217 execute_write_query(
218 self.client,
219 delete_file_node_query(self.project_id, file_path)?,
220 )
221 }
222
223 pub fn delete_file_projection(&mut self, file_path: &str) -> anyhow::Result<()> {
224 self.delete_file_graph(file_path, &[])?;
225 self.delete_file_node(file_path)?;
226 self.cleanup_orphans()
227 }
228
229 pub fn cleanup_orphans(&mut self) -> anyhow::Result<()> {
230 for query in cleanup_orphans_queries(self.project_id)? {
231 execute_write_query(self.client, query)?;
232 }
233 Ok(())
234 }
235
236 pub fn cleanup_deleted_files(
237 &mut self,
238 indexed_file_paths: &HashSet<String>,
239 ) -> anyhow::Result<GraphOrphanCleanup> {
240 let graph_file_paths = self.project_file_paths()?;
241 let stale_file_paths = graph_file_paths
242 .into_iter()
243 .filter(|file_path| !indexed_file_paths.contains(file_path))
244 .collect::<Vec<_>>();
245 let mut graph_nodes_deleted = 0;
246
247 for file_path in &stale_file_paths {
248 graph_nodes_deleted += self.count_file_projection_nodes(file_path)?;
249 self.delete_file_graph(file_path, &[])?;
250 self.delete_file_node(file_path)?;
251 }
252
253 self.cleanup_orphans()?;
254 Ok(GraphOrphanCleanup {
255 stale_files_deleted: stale_file_paths.len(),
256 graph_nodes_deleted,
257 })
258 }
259
260 fn project_file_paths(&mut self) -> anyhow::Result<BTreeSet<String>> {
261 let mut file_paths = BTreeSet::new();
262 for query in project_file_path_queries(self.project_id)? {
263 let crate::graph::typed_query::TypedQuery { cypher, params } = query;
264 for row in self.client.query(&cypher, Some(params))? {
265 if let Some(file_path) = row.get("path").and_then(Value::as_str) {
266 file_paths.insert(file_path.to_string());
267 }
268 }
269 }
270 Ok(file_paths)
271 }
272
273 fn count_file_projection_nodes(&mut self, file_path: &str) -> anyhow::Result<usize> {
274 let query = count_file_projection_nodes_query(self.project_id, file_path)?;
275 let crate::graph::typed_query::TypedQuery { cypher, params } = query;
276 let rows = self.client.query(&cypher, Some(params))?;
277 Ok(rows
278 .first()
279 .and_then(|row| row.get("nodes"))
280 .and_then(value_to_usize)
281 .unwrap_or(0))
282 }
283
284 pub fn clear_project(&mut self) -> anyhow::Result<()> {
285 execute_write_query(self.client, clear_project_query(self.project_id)?)
286 }
287}
288
289fn value_to_usize(value: &Value) -> Option<usize> {
290 if let Some(value) = value.as_u64() {
291 return usize::try_from(value).ok();
292 }
293 value.as_i64().and_then(|value| usize::try_from(value).ok())
294}
295
296pub fn sync_file_graph(
297 ctx: &Context,
298 file_path: &str,
299 imports: &[ImportRelation],
300 definitions: &[Symbol],
301 calls: &[CallRelation],
302 cleanup_orphans: bool,
303) -> anyhow::Result<usize> {
304 with_code_graph(ctx, |graph| {
305 graph.sync_file(file_path, imports, definitions, calls, cleanup_orphans)
306 })
307}
308
309pub fn with_code_graph<T>(
310 ctx: &Context,
311 f: impl FnOnce(&mut CodeGraph<'_>) -> anyhow::Result<T>,
312) -> anyhow::Result<T> {
313 with_required_core_graph(ctx, |client| {
314 let mut graph = CodeGraph::new(&ctx.project_id, client);
315 graph.ensure_project_indexes()?;
316 f(&mut graph)
317 })
318}
319
320pub fn delete_file_graph(
321 ctx: &Context,
322 file_path: &str,
323 current_symbol_ids: &[String],
324) -> anyhow::Result<()> {
325 with_required_core_graph(ctx, |client| {
326 CodeGraph::new(&ctx.project_id, client).delete_file_graph(file_path, current_symbol_ids)
327 })
328}
329
330pub fn delete_file_projection(ctx: &Context, file_path: &str) -> anyhow::Result<()> {
331 with_required_core_graph(ctx, |client| {
332 CodeGraph::new(&ctx.project_id, client).delete_file_projection(file_path)
333 })
334}
335
336pub fn cleanup_orphans(ctx: &Context) -> anyhow::Result<()> {
337 with_code_graph(ctx, |graph| graph.cleanup_orphans())
338}
339
340pub fn cleanup_deleted_files(
341 ctx: &Context,
342 indexed_file_paths: &HashSet<String>,
343) -> anyhow::Result<GraphOrphanCleanup> {
344 with_code_graph(ctx, |graph| graph.cleanup_deleted_files(indexed_file_paths))
345}
346
347pub fn clear_project(ctx: &Context) -> anyhow::Result<()> {
348 with_required_core_graph(ctx, |client| {
349 CodeGraph::new(&ctx.project_id, client).clear_project()
350 })
351}
352
353pub fn clear_all_code_index(config: &crate::config::FalkorConfig) -> anyhow::Result<()> {
354 let connection_config = config.connection_config();
355 match gobby_core::falkor::with_graph(
356 Some(&connection_config),
357 &config.graph_name,
358 None,
359 |client| execute_write_query(client, clear_all_code_index_query()?).map(Some),
360 ) {
361 Ok((Some(()), ServiceState::Available)) => Ok(()),
362 Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
363 Ok((_, ServiceState::Unreachable { message })) => {
364 log::warn!("FalkorDB was unreachable while clearing code graph: {message}");
365 Err(GraphReadError::Unreachable { message }.into())
366 }
367 Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
368 message: "graph clear returned no value".to_string(),
369 }
370 .into()),
371 Err(error) => Err(GraphReadError::QueryFailed {
372 message: format!("{error:#}"),
373 }
374 .into()),
375 }
376}