1use rusqlite::{Connection, OpenFlags};
2use std::collections::BTreeSet;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime};
5
6use crate::inspect::job::{CallgraphExport, CallgraphOutboundCall, CallgraphSnapshot};
7use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
8
9use super::{database_ready, CallGraphStoreError, Result, BACKEND_TREESITTER};
10
11pub fn project_dead_code_snapshot(db_path: &Path) -> Result<CallgraphSnapshot> {
12 if !db_path.is_file() {
13 return Err(CallGraphStoreError::Unavailable(format!(
14 "database does not exist: {}",
15 db_path.display()
16 )));
17 }
18
19 let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
20 conn.busy_timeout(Duration::from_millis(5_000))?;
21 if !database_ready(&conn).unwrap_or(false) {
22 return Err(CallGraphStoreError::Unavailable(
23 "database is missing, stale, or mid-build".to_string(),
24 ));
25 }
26
27 let project_root = project_root_from_backend_state(&conn)?;
28 let files = project_files_from_store(&conn, &project_root)?;
29 let exported_symbols = exported_symbols_from_store(&conn, &project_root)?;
30 let outbound_calls = outbound_calls_from_store(&conn, &project_root)?;
31 let entry_points = entry_points_for_files(&project_root, &files);
32
33 Ok(CallgraphSnapshot {
34 generated_at: Some(SystemTime::now()),
35 files,
36 exported_symbols,
37 outbound_calls,
38 entry_points,
39 })
40}
41
42fn project_root_from_backend_state(conn: &Connection) -> Result<PathBuf> {
43 let mut statement = conn.prepare(
44 "SELECT DISTINCT workspace_root
45 FROM backend_file_state
46 WHERE backend = ?1
47 ORDER BY workspace_root",
48 )?;
49 let roots = statement
50 .query_map([BACKEND_TREESITTER], |row| row.get::<_, String>(0))?
51 .collect::<std::result::Result<Vec<_>, _>>()?;
52
53 match roots.as_slice() {
54 [root] => Ok(PathBuf::from(root)),
55 [] => Err(CallGraphStoreError::Unavailable(
56 "database has no workspace root rows".to_string(),
57 )),
58 _ => Err(CallGraphStoreError::Unavailable(format!(
59 "database has multiple workspace roots: {}",
60 roots.join(", ")
61 ))),
62 }
63}
64
65fn project_files_from_store(conn: &Connection, project_root: &Path) -> Result<Vec<PathBuf>> {
66 let mut statement = conn.prepare("SELECT path FROM files ORDER BY path")?;
67 let files = statement
68 .query_map([], |row| row.get::<_, String>(0))?
69 .map(|path| {
70 path.map(|path| canonicalize_for_snapshot(&absolute_store_path(project_root, &path)))
71 })
72 .collect::<std::result::Result<Vec<_>, _>>()?;
73 Ok(files)
74}
75
76fn exported_symbols_from_store(
77 conn: &Connection,
78 project_root: &Path,
79) -> Result<Vec<CallgraphExport>> {
80 let mut statement = conn.prepare(
81 "SELECT file_path, name, kind, start_line, exported, is_default_export
82 FROM nodes
83 WHERE exported != 0 OR is_default_export != 0
84 ORDER BY file_path, start_line, name, kind, id",
85 )?;
86 let rows = statement.query_map([], |row| {
87 Ok(ExportRow {
88 file_path: row.get(0)?,
89 name: row.get(1)?,
90 kind: row.get(2)?,
91 line: (row.get::<_, i64>(3)?.max(0) as u32).saturating_add(1),
92 exported: row.get::<_, i64>(4)? != 0,
93 is_default_export: row.get::<_, i64>(5)? != 0,
94 })
95 })?;
96
97 let mut exports = Vec::new();
98 for row in rows {
99 let row = row?;
100 let file = canonicalize_for_snapshot(&absolute_store_path(project_root, &row.file_path));
101 if row.exported {
102 exports.push(CallgraphExport {
103 file: file.clone(),
104 symbol: row.name.clone(),
105 kind: row.kind,
106 line: row.line,
107 });
108 }
109 if row.is_default_export {
110 exports.push(CallgraphExport {
111 file,
112 symbol: row.name,
113 kind: DEFAULT_EXPORT_MARKER_KIND.to_string(),
114 line: row.line,
115 });
116 }
117 }
118 Ok(exports)
119}
120
121fn outbound_calls_from_store(
122 conn: &Connection,
123 project_root: &Path,
124) -> Result<Vec<CallgraphOutboundCall>> {
125 let mut statement = conn.prepare(
126 "SELECT r.ref_id,
127 r.caller_file,
128 n.scoped_name,
129 r.short_name,
130 r.full_ref,
131 r.status,
132 COALESCE(r.target_file, e.target_file),
133 COALESCE(r.target_symbol, e.target_symbol),
134 r.line
135 FROM refs r
136 LEFT JOIN nodes n ON n.id = r.caller_node
137 LEFT JOIN edges e ON e.ref_id = r.ref_id AND e.kind = 'call'
138 WHERE r.kind = 'call'
139 ORDER BY r.caller_file, n.scoped_name, r.line, r.byte_start, r.byte_end, r.ref_id",
140 )?;
141 let rows = statement.query_map([], |row| {
142 Ok(OutboundRow {
143 ref_id: row.get(0)?,
144 caller_file: row.get(1)?,
145 caller_symbol: row.get(2)?,
146 short_name: row.get(3)?,
147 full_ref: row.get(4)?,
148 status: row.get(5)?,
149 target_file: row.get(6)?,
150 target_symbol: row.get(7)?,
151 line: row.get::<_, i64>(8)? as u32,
152 })
153 })?;
154
155 let mut calls = Vec::new();
156 for row in rows {
157 let row = row?;
158 let caller_file =
159 canonicalize_for_snapshot(&absolute_store_path(project_root, &row.caller_file));
160 let caller_symbol = caller_symbol_from_row(&row)?;
161 let short_name = row
162 .short_name
163 .as_deref()
164 .or(row.full_ref.as_deref())
165 .unwrap_or_default();
166 let mut target = if is_resolved_status(&row.status) {
167 match (row.target_file.as_deref(), row.target_symbol.as_deref()) {
168 (Some(target_file), Some(target_symbol)) => {
169 let target_file =
170 canonicalize_for_snapshot(&absolute_store_path(project_root, target_file));
171 format!("{}::{target_symbol}", target_file.display())
172 }
173 _ => short_name.to_string(),
174 }
175 } else {
176 short_name.to_string()
177 };
178
179 if row
180 .full_ref
181 .as_deref()
182 .is_some_and(|full_ref| is_method_dispatch_callee(full_ref, short_name))
183 {
184 target.push(crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR);
185 target.push_str(row.full_ref.as_deref().unwrap_or_default());
186 }
187
188 calls.push(CallgraphOutboundCall {
189 caller_file,
190 caller_symbol,
191 target,
192 line: row.line,
193 });
194 }
195 Ok(calls)
196}
197
198fn entry_points_for_files(project_root: &Path, files: &[PathBuf]) -> BTreeSet<PathBuf> {
199 let resolved_entry_points = crate::inspect::resolve_entry_points(project_root);
200 files
201 .iter()
202 .filter(|file| resolved_entry_points.is_entry_point(file))
203 .cloned()
204 .collect()
205}
206
207fn caller_symbol_from_row(row: &OutboundRow) -> Result<String> {
208 row.caller_symbol.clone().ok_or_else(|| {
209 CallGraphStoreError::Unavailable(format!(
210 "call ref {} is missing caller symbol",
211 row.ref_id
212 ))
213 })
214}
215
216fn is_resolved_status(status: &str) -> bool {
217 matches!(status, "resolved" | "resolved_local")
218}
219
220fn is_method_dispatch_callee(full_callee: &str, callee_name: &str) -> bool {
221 let full_callee = full_callee.trim();
222 if !full_callee.contains('.') || full_callee == callee_name.trim() {
223 return false;
224 }
225
226 full_callee
227 .rsplit('.')
228 .next()
229 .map(|segment| segment.trim().trim_start_matches('?') == callee_name.trim())
230 .unwrap_or(false)
231}
232
233fn absolute_store_path(project_root: &Path, store_path: &str) -> PathBuf {
234 let path = Path::new(store_path);
235 if path.is_absolute() {
236 path.to_path_buf()
237 } else {
238 project_root.join(path)
239 }
240}
241
242fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
243 std::fs::canonicalize(path).unwrap_or_else(|_| crate::inspect::job::normalize_path(path))
244}
245
246#[derive(Debug)]
247struct ExportRow {
248 file_path: String,
249 name: String,
250 kind: String,
251 line: u32,
252 exported: bool,
253 is_default_export: bool,
254}
255
256#[derive(Debug)]
257struct OutboundRow {
258 ref_id: String,
259 caller_file: String,
260 caller_symbol: Option<String>,
261 short_name: Option<String>,
262 full_ref: Option<String>,
263 status: String,
264 target_file: Option<String>,
265 target_symbol: Option<String>,
266 line: u32,
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn assert_send<T: Send>() {}
274
275 #[test]
276 fn projection_result_is_send() {
277 assert_send::<Result<CallgraphSnapshot>>();
278 }
279}