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.into_iter()
221 .map(|row| {
222 let target_kind: String = row.try_get("callee_target_kind")?;
223 let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
224 let callee_external_module: String = row.try_get("callee_external_module")?;
225 Ok(CallRelation {
226 caller_symbol_id: row.try_get("caller_symbol_id")?,
227 callee_symbol_id: non_empty(callee_symbol_id),
228 callee_name: row.try_get("callee_name")?,
229 callee_target_kind: call_target_kind_from_str(&target_kind)?,
230 callee_external_module: non_empty(callee_external_module),
231 file_path: row.try_get("file_path")?,
232 line: i64_to_usize(row.try_get("line")?, "line")?,
233 })
234 })
235 .collect()
236}
237
238fn non_empty(value: String) -> Option<String> {
239 if value.is_empty() { None } else { Some(value) }
240}
241
242fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
243 match value {
244 "symbol" => Ok(CallTargetKind::Symbol),
245 "unresolved" => Ok(CallTargetKind::Unresolved),
246 "external" => Ok(CallTargetKind::External),
247 other => bail!("unknown code_calls.callee_target_kind `{other}`"),
248 }
249}
250
251pub fn symbol_select_columns(alias: &str) -> String {
252 assert!(
253 safe_symbol_select_alias(alias),
254 "symbol_select_columns alias must be empty or a safe SQL identifier"
255 );
256 let prefix = if alias.is_empty() {
257 String::new()
258 } else {
259 format!("{alias}.")
260 };
261 format!(
262 "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
263 {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
264 {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
265 {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
266 {p}parent_symbol_id, {p}content_hash, {p}summary, \
267 {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
268 p = prefix
269 )
270}
271
272fn safe_symbol_select_alias(alias: &str) -> bool {
273 if alias.is_empty() {
274 return true;
275 }
276 let mut chars = alias.chars();
277 chars
278 .next()
279 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
280 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn symbol_select_columns_accepts_empty_or_safe_alias() {
289 assert!(symbol_select_columns("").starts_with("id, project_id"));
290 assert!(symbol_select_columns("cs").starts_with("cs.id, cs.project_id"));
291 assert!(symbol_select_columns("_symbols1").starts_with("_symbols1.id"));
292 }
293
294 #[test]
295 #[should_panic(expected = "safe SQL identifier")]
296 fn symbol_select_columns_rejects_unsafe_alias() {
297 let _ = symbol_select_columns("cs;DROP TABLE code_symbols");
298 }
299}