1use anyhow::bail;
2use postgres::GenericClient;
3
4use crate::models::{CallRelation, CallTargetKind, ImportRelation, Symbol};
5use crate::utils::i64_to_usize;
6
7#[derive(Debug, Clone)]
8pub struct GraphFileFacts {
9 pub file_path: String,
10 pub imports: Vec<ImportRelation>,
11 pub definitions: Vec<Symbol>,
12 pub calls: Vec<CallRelation>,
13}
14
15pub fn list_indexed_file_paths(
16 conn: &mut impl GenericClient,
17 project_id: &str,
18) -> anyhow::Result<Vec<String>> {
19 let rows = conn.query(
20 "SELECT file_path FROM code_indexed_files WHERE project_id = $1 ORDER BY file_path",
21 &[&project_id],
22 )?;
23 rows.into_iter()
24 .map(|row| row.try_get("file_path").map_err(Into::into))
25 .collect()
26}
27
28pub fn indexed_project_exists(
29 conn: &mut impl GenericClient,
30 project_id: &str,
31) -> anyhow::Result<bool> {
32 Ok(conn
33 .query_opt(
34 "SELECT 1 FROM code_indexed_projects WHERE id = $1",
35 &[&project_id],
36 )?
37 .is_some())
38}
39
40pub fn read_graph_file_facts(
41 conn: &mut impl GenericClient,
42 project_id: &str,
43 file_path: &str,
44) -> anyhow::Result<GraphFileFacts> {
45 let imports = read_imports_for_file(conn, project_id, file_path)?;
46 let definitions = read_symbols_for_file(conn, project_id, file_path)?;
47 let calls = read_calls_for_file(conn, project_id, file_path)?;
48
49 Ok(GraphFileFacts {
50 file_path: file_path.to_string(),
51 imports,
52 definitions,
53 calls,
54 })
55}
56
57pub fn indexed_file_exists(
58 conn: &mut impl GenericClient,
59 project_id: &str,
60 file_path: &str,
61) -> anyhow::Result<bool> {
62 Ok(conn
63 .query_opt(
64 "SELECT 1 FROM code_indexed_files
65 WHERE project_id = $1 AND file_path = $2",
66 &[&project_id, &file_path],
67 )?
68 .is_some())
69}
70
71pub fn mark_graph_sync_attempted(
72 conn: &mut impl GenericClient,
73 project_id: &str,
74 file_path: &str,
75) -> anyhow::Result<bool> {
76 let updated = conn.execute(
77 "UPDATE code_indexed_files
78 SET graph_synced = false, graph_sync_attempted_at = NOW()
79 WHERE project_id = $1 AND file_path = $2",
80 &[&project_id, &file_path],
81 )?;
82 Ok(updated > 0)
83}
84
85pub fn mark_graph_synced(
86 conn: &mut impl GenericClient,
87 project_id: &str,
88 file_path: &str,
89) -> anyhow::Result<bool> {
90 let updated = conn.execute(
91 "UPDATE code_indexed_files
92 SET graph_synced = true, graph_sync_attempted_at = NOW()
93 WHERE project_id = $1 AND file_path = $2",
94 &[&project_id, &file_path],
95 )?;
96 Ok(updated > 0)
97}
98
99pub fn reset_graph_sync_for_project(
100 conn: &mut impl GenericClient,
101 project_id: &str,
102) -> anyhow::Result<u64> {
103 Ok(conn.execute(
104 "UPDATE code_indexed_files
105 SET graph_synced = false, graph_sync_attempted_at = NULL
106 WHERE project_id = $1",
107 &[&project_id],
108 )?)
109}
110
111pub fn mark_vectors_synced(
112 conn: &mut impl GenericClient,
113 project_id: &str,
114 file_path: &str,
115) -> anyhow::Result<bool> {
116 let updated = conn.execute(
117 "UPDATE code_indexed_files
118 SET vectors_synced = true
119 WHERE project_id = $1 AND file_path = $2",
120 &[&project_id, &file_path],
121 )?;
122 Ok(updated > 0)
123}
124
125pub fn mark_project_vectors_synced(
126 conn: &mut impl GenericClient,
127 project_id: &str,
128) -> anyhow::Result<u64> {
129 Ok(conn.execute(
130 "UPDATE code_indexed_files
131 SET vectors_synced = true
132 WHERE project_id = $1",
133 &[&project_id],
134 )?)
135}
136
137pub fn file_vectors_synced(
142 conn: &mut impl GenericClient,
143 project_id: &str,
144 file_path: &str,
145) -> anyhow::Result<Option<bool>> {
146 let synced = conn
147 .query_opt(
148 "SELECT vectors_synced
149 FROM code_indexed_files
150 WHERE project_id = $1 AND file_path = $2",
151 &[&project_id, &file_path],
152 )?
153 .map(|row| row.try_get::<_, bool>("vectors_synced"))
154 .transpose()?;
155 Ok(synced)
156}
157
158pub fn reset_vectors_sync_for_project(
159 conn: &mut impl GenericClient,
160 project_id: &str,
161) -> anyhow::Result<u64> {
162 Ok(conn.execute(
163 "UPDATE code_indexed_files
164 SET vectors_synced = false
165 WHERE project_id = $1",
166 &[&project_id],
167 )?)
168}
169
170fn read_imports_for_file(
171 conn: &mut impl GenericClient,
172 project_id: &str,
173 file_path: &str,
174) -> anyhow::Result<Vec<ImportRelation>> {
175 let rows = conn.query(
176 "SELECT source_file, target_module
177 FROM code_imports
178 WHERE project_id = $1 AND source_file = $2
179 ORDER BY target_module",
180 &[&project_id, &file_path],
181 )?;
182 rows.into_iter()
183 .map(|row| {
184 Ok(ImportRelation {
185 file_path: row.try_get("source_file")?,
186 module_name: row.try_get("target_module")?,
187 })
188 })
189 .collect()
190}
191
192fn read_symbols_for_file(
193 conn: &mut impl GenericClient,
194 project_id: &str,
195 file_path: &str,
196) -> anyhow::Result<Vec<Symbol>> {
197 let query = format!(
198 "SELECT {} FROM code_symbols s
199 WHERE s.project_id = $1 AND s.file_path = $2
200 ORDER BY s.line_start, s.byte_start",
201 symbol_select_columns("s")
202 );
203 let rows = conn.query(&query, &[&project_id, &file_path])?;
204 rows.iter().map(Symbol::from_row).collect()
205}
206
207fn read_calls_for_file(
208 conn: &mut impl GenericClient,
209 project_id: &str,
210 file_path: &str,
211) -> anyhow::Result<Vec<CallRelation>> {
212 let rows = conn.query(
213 "SELECT caller_symbol_id, callee_symbol_id, callee_name,
214 callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
215 FROM code_calls
216 WHERE project_id = $1 AND file_path = $2
217 ORDER BY line, caller_symbol_id, callee_name",
218 &[&project_id, &file_path],
219 )?;
220 rows.iter().map(call_relation_from_row).collect()
221}
222
223fn call_relation_from_row(row: &postgres::Row) -> anyhow::Result<CallRelation> {
224 let target_kind: String = row.try_get("callee_target_kind")?;
225 let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
226 let callee_external_module: String = row.try_get("callee_external_module")?;
227 Ok(CallRelation {
228 caller_symbol_id: row.try_get("caller_symbol_id")?,
229 callee_symbol_id: non_empty(callee_symbol_id),
230 callee_name: row.try_get("callee_name")?,
231 callee_target_kind: call_target_kind_from_str(&target_kind)?,
232 callee_external_module: non_empty(callee_external_module),
233 file_path: row.try_get("file_path")?,
234 line: i64_to_usize(row.try_get("line")?, "line")?,
235 })
236}
237
238pub fn read_local_import_calls(
242 conn: &mut impl GenericClient,
243 project_id: &str,
244 file_paths: &[String],
245) -> anyhow::Result<Vec<CallRelation>> {
246 if file_paths.is_empty() {
247 return Ok(Vec::new());
248 }
249 let rows = conn.query(
250 "SELECT caller_symbol_id, callee_symbol_id, callee_name,
251 callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
252 FROM code_calls
253 WHERE project_id = $1 AND file_path = ANY($2)
254 AND callee_target_kind = 'local_import'
255 ORDER BY file_path, line, caller_symbol_id, callee_name",
256 &[&project_id, &file_paths],
257 )?;
258 rows.iter().map(call_relation_from_row).collect()
259}
260
261pub fn read_project_local_import_calls(
262 conn: &mut impl GenericClient,
263 project_id: &str,
264) -> anyhow::Result<Vec<CallRelation>> {
265 let rows = conn.query(
266 "SELECT caller_symbol_id, callee_symbol_id, callee_name,
267 callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
268 FROM code_calls
269 WHERE project_id = $1 AND callee_target_kind = 'local_import'
270 ORDER BY file_path, line, caller_symbol_id, callee_name",
271 &[&project_id],
272 )?;
273 rows.iter().map(call_relation_from_row).collect()
274}
275
276pub fn resolve_local_callee_symbol_id(
290 conn: &mut impl GenericClient,
291 project_id: &str,
292 target_files: &[String],
293 name: &str,
294) -> anyhow::Result<Option<String>> {
295 if target_files.is_empty() || name.is_empty() {
296 return Ok(None);
297 }
298 let rows = conn.query(
299 "SELECT id, kind, parent_symbol_id
300 FROM code_symbols
301 WHERE project_id = $1 AND file_path = ANY($2) AND name = $3
302 ORDER BY file_path, byte_start",
303 &[&project_id, &target_files, &name],
304 )?;
305
306 let candidates: Vec<LocalCalleeCandidate> = rows
307 .iter()
308 .map(|row| {
309 let id: String = row.try_get("id")?;
310 let kind: String = row.try_get("kind")?;
311 let parent_symbol_id: Option<String> = row.try_get("parent_symbol_id")?;
312 Ok::<_, anyhow::Error>(LocalCalleeCandidate {
313 id,
314 kind,
315 parent_symbol_id,
316 })
317 })
318 .collect::<Result<_, _>>()?;
319
320 Ok(select_local_callee_candidate_id(&candidates))
321}
322
323pub fn resolve_default_import_symbol_id(
324 conn: &mut impl GenericClient,
325 project_id: &str,
326 target_files: &[String],
327) -> anyhow::Result<Option<String>> {
328 if target_files.is_empty() {
329 return Ok(None);
330 }
331 let target_kinds = ["function", "class", "type"];
332 let rows = conn.query(
333 "SELECT id, kind, parent_symbol_id
334 FROM code_symbols
335 WHERE project_id = $1 AND file_path = ANY($2)
336 AND parent_symbol_id IS NULL
337 AND kind = ANY($3)
338 ORDER BY file_path, byte_start",
339 &[&project_id, &target_files, &target_kinds.as_slice()],
340 )?;
341
342 let candidates: Vec<LocalCalleeCandidate> = rows
343 .iter()
344 .map(|row| {
345 let id: String = row.try_get("id")?;
346 let kind: String = row.try_get("kind")?;
347 let parent_symbol_id: Option<String> = row.try_get("parent_symbol_id")?;
348 Ok::<_, anyhow::Error>(LocalCalleeCandidate {
349 id,
350 kind,
351 parent_symbol_id,
352 })
353 })
354 .collect::<Result<_, _>>()?;
355
356 Ok(select_default_import_candidate_id(&candidates))
357}
358
359#[derive(Debug)]
360struct LocalCalleeCandidate {
361 id: String,
362 kind: String,
363 parent_symbol_id: Option<String>,
364}
365
366fn select_local_callee_candidate_id(candidates: &[LocalCalleeCandidate]) -> Option<String> {
367 let top_level: Vec<&String> = candidates
368 .iter()
369 .filter(|candidate| {
370 candidate.parent_symbol_id.is_none()
371 && matches!(candidate.kind.as_str(), "function" | "class")
372 })
373 .map(|candidate| &candidate.id)
374 .collect();
375 if !top_level.is_empty() {
376 return unique_id(&top_level);
377 }
378
379 let methods: Vec<&String> = candidates
380 .iter()
381 .filter(|candidate| candidate.kind == "method")
382 .map(|candidate| &candidate.id)
383 .collect();
384 if !methods.is_empty() {
385 return unique_id(&methods);
386 }
387
388 let module_scoped_functions: Vec<&String> = candidates
394 .iter()
395 .filter(|candidate| candidate.parent_symbol_id.is_some() && candidate.kind == "function")
396 .map(|candidate| &candidate.id)
397 .collect();
398 if !module_scoped_functions.is_empty() {
399 return unique_id(&module_scoped_functions);
400 }
401
402 let types: Vec<&String> = candidates
408 .iter()
409 .filter(|candidate| candidate.parent_symbol_id.is_none() && candidate.kind == "type")
410 .map(|candidate| &candidate.id)
411 .collect();
412 unique_id(&types)
413}
414
415fn select_default_import_candidate_id(candidates: &[LocalCalleeCandidate]) -> Option<String> {
416 let top_level: Vec<&String> = candidates
417 .iter()
418 .filter(|candidate| {
419 candidate.parent_symbol_id.is_none()
420 && matches!(candidate.kind.as_str(), "function" | "class" | "type")
421 })
422 .map(|candidate| &candidate.id)
423 .collect();
424 unique_id(&top_level)
425}
426
427fn unique_id(ids: &[&String]) -> Option<String> {
428 match ids {
429 [single] => Some((*single).clone()),
430 _ => None,
431 }
432}
433
434fn non_empty(value: String) -> Option<String> {
435 if value.is_empty() { None } else { Some(value) }
436}
437
438fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
439 match value {
440 "symbol" => Ok(CallTargetKind::Symbol),
441 "unresolved" => Ok(CallTargetKind::Unresolved),
442 "external" => Ok(CallTargetKind::External),
443 "local_import" => Ok(CallTargetKind::LocalImport),
447 other => bail!("unknown code_calls.callee_target_kind `{other}`"),
448 }
449}
450
451pub fn symbol_select_columns(alias: &str) -> String {
452 assert!(
453 safe_symbol_select_alias(alias),
454 "symbol_select_columns alias must be empty or a safe SQL identifier"
455 );
456 let prefix = if alias.is_empty() {
457 String::new()
458 } else {
459 format!("{alias}.")
460 };
461 format!(
462 "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
463 {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
464 {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
465 {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
466 {p}parent_symbol_id, {p}content_hash, {p}summary, \
467 {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
468 p = prefix
469 )
470}
471
472fn safe_symbol_select_alias(alias: &str) -> bool {
473 if alias.is_empty() {
474 return true;
475 }
476 let mut chars = alias.chars();
477 chars
478 .next()
479 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
480 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 fn code_symbol_row(
488 id: &str,
489 kind: &str,
490 parent_symbol_id: Option<&str>,
491 ) -> LocalCalleeCandidate {
492 LocalCalleeCandidate {
493 id: id.to_string(),
494 kind: kind.to_string(),
495 parent_symbol_id: parent_symbol_id.map(str::to_string),
496 }
497 }
498
499 #[test]
500 fn resolves_unique_module_scoped_function_candidate() {
501 let candidates = [code_symbol_row("greet-fn", "function", Some("app-greeter"))];
502
503 assert_eq!(
504 select_local_callee_candidate_id(&candidates),
505 Some("greet-fn".to_string())
506 );
507 }
508
509 #[test]
510 fn method_tier_precedes_module_scoped_function_candidates() {
511 let candidates = [
512 code_symbol_row("greet-fn", "function", Some("app-greeter")),
513 code_symbol_row("greet-method", "method", Some("app-greeter")),
514 ];
515
516 assert_eq!(
517 select_local_callee_candidate_id(&candidates),
518 Some("greet-method".to_string())
519 );
520 }
521
522 #[test]
523 fn leaves_ambiguous_module_scoped_function_candidates_unresolved() {
524 let candidates = [
525 code_symbol_row("greet-1", "function", Some("app-greeter")),
526 code_symbol_row("greet-2", "function", Some("app-greeter")),
527 ];
528
529 assert_eq!(select_local_callee_candidate_id(&candidates), None);
530 }
531
532 #[test]
533 fn default_import_selector_resolves_unique_top_level_candidate() {
534 let candidates = [
535 code_symbol_row("helper", "function", None),
536 code_symbol_row("nested", "function", Some("helper")),
537 code_symbol_row("method", "method", Some("helper")),
538 ];
539
540 assert_eq!(
541 select_default_import_candidate_id(&candidates),
542 Some("helper".to_string())
543 );
544 }
545
546 #[test]
547 fn default_import_selector_leaves_ambiguous_top_level_candidates_unresolved() {
548 let candidates = [
549 code_symbol_row("helper", "function", None),
550 code_symbol_row("Widget", "class", None),
551 ];
552
553 assert_eq!(select_default_import_candidate_id(&candidates), None);
554 }
555
556 #[test]
557 fn symbol_select_columns_accepts_empty_or_safe_alias() {
558 assert!(symbol_select_columns("").starts_with("id, project_id"));
559 assert!(symbol_select_columns("cs").starts_with("cs.id, cs.project_id"));
560 assert!(symbol_select_columns("_symbols1").starts_with("_symbols1.id"));
561 }
562
563 #[test]
564 #[should_panic(expected = "safe SQL identifier")]
565 fn symbol_select_columns_rejects_unsafe_alias() {
566 let _ = symbol_select_columns("cs;DROP TABLE code_symbols");
567 }
568}