1use std::collections::{HashMap, HashSet};
7
8use crate::core::call_graph::{CallGraph, CallGraphInputs};
9use crate::core::graph_index::{self, ProjectIndex, SymbolEntry};
10
11#[derive(Debug, Clone)]
13pub struct SymbolDef {
14 pub name: String,
15 pub kind: String,
16 pub file: String,
17 pub line: usize,
18 pub end_line: usize,
19 pub is_exported: bool,
20 pub signature: String,
21}
22
23pub struct RepoGraph {
25 pub files: HashSet<String>,
26 pub forward: HashMap<String, Vec<String>>,
28 pub symbols_by_file: HashMap<String, Vec<SymbolDef>>,
30}
31
32impl RepoGraph {
33 pub fn build(project_root: &str) -> Self {
38 let (index, content_cache) = graph_index::scan_with_content_cache(project_root);
39 let calls = graph_call_pairs(project_root).unwrap_or_else(|| {
40 let cg_inputs = CallGraphInputs::from_project_index(&index);
41 let call_graph = CallGraph::load_or_build(project_root, &cg_inputs);
42 structural_call_pairs(&cg_inputs, &call_graph)
43 });
44
45 Self::from_index_and_calls(&index, &calls, &content_cache)
46 }
47
48 fn from_index_and_calls(
50 index: &ProjectIndex,
51 calls: &[(String, String)],
52 content_cache: &HashMap<String, String>,
53 ) -> Self {
54 let files: HashSet<String> = index.files.keys().cloned().collect();
55
56 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
57
58 for edge in &index.edges {
60 if files.contains(&edge.from) && files.contains(&edge.to) && edge.from != edge.to {
61 forward
62 .entry(edge.from.clone())
63 .or_default()
64 .push(edge.to.clone());
65 }
66 }
67
68 for (from, to) in calls {
70 if files.contains(from) && files.contains(to) && from != to {
71 forward.entry(from.clone()).or_default().push(to.clone());
72 }
73 }
74
75 for deps in forward.values_mut() {
77 deps.sort();
78 deps.dedup();
79 }
80
81 let symbols_by_file = build_symbols_with_signatures(index, content_cache);
82
83 Self {
84 files,
85 forward,
86 symbols_by_file,
87 }
88 }
89}
90
91fn graph_call_pairs(project_root: &str) -> Option<Vec<(String, String)>> {
99 use crate::core::property_graph::{CodeGraph, EdgeKind};
100 if crate::core::property_graph::engine_outdated(project_root) {
101 return None;
102 }
103 let graph = CodeGraph::open(project_root).ok()?;
104 let pairs: Vec<(String, String)> = graph
105 .file_edges_of_kind(&EdgeKind::Calls)
106 .ok()?
107 .into_iter()
108 .filter(|(from, to, _)| graph.serves_file(from) && graph.serves_file(to))
111 .map(|(from, to, _)| (from, to))
112 .collect();
113 (!pairs.is_empty() || graph.has_semantic_answers().unwrap_or(false)).then_some(pairs)
114}
115
116fn structural_call_pairs(
120 inputs: &CallGraphInputs,
121 call_graph: &CallGraph,
122) -> Vec<(String, String)> {
123 crate::core::call_graph::resolve_edge_callee_files(inputs, &call_graph.edges)
124 .into_iter()
125 .zip(&call_graph.edges)
126 .filter_map(|(to, edge)| Some((edge.caller_file.clone(), to?)))
127 .collect()
128}
129
130fn build_symbols_with_signatures(
132 index: &ProjectIndex,
133 content_cache: &HashMap<String, String>,
134) -> HashMap<String, Vec<SymbolDef>> {
135 let mut result: HashMap<String, Vec<SymbolDef>> = HashMap::new();
136
137 let mut idx_symbols: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
139 for sym in index.symbols.values() {
140 idx_symbols.entry(sym.file.as_str()).or_default().push(sym);
141 }
142
143 for (file_path, file_entry) in &index.files {
144 let ext = std::path::Path::new(file_path)
145 .extension()
146 .and_then(|e| e.to_str())
147 .unwrap_or("");
148
149 let signatures = content_cache
151 .get(file_path)
152 .map(|content| crate::core::signatures::extract_signatures(content, ext))
153 .unwrap_or_default();
154
155 let sig_by_name: HashMap<&str, &crate::core::signatures::Signature> =
156 signatures.iter().map(|s| (s.name.as_str(), s)).collect();
157
158 let mut file_symbols: Vec<SymbolDef> = Vec::new();
159
160 if let Some(syms) = idx_symbols.get(file_path.as_str()) {
161 for sym in syms {
162 let signature = sig_by_name
163 .get(sym.name.as_str())
164 .map_or_else(|| format!("{} {}", sym.kind, sym.name), |s| s.to_compact());
165
166 file_symbols.push(SymbolDef {
167 name: sym.name.clone(),
168 kind: sym.kind.clone(),
169 file: sym.file.clone(),
170 line: sym.start_line,
171 end_line: sym.end_line,
172 is_exported: sym.is_exported,
173 signature,
174 });
175 }
176 }
177
178 for export in &file_entry.exports {
180 let already_present = file_symbols.iter().any(|s| s.name == *export);
181 if !already_present {
182 let signature = sig_by_name
183 .get(export.as_str())
184 .map_or_else(|| export.clone(), |s| s.to_compact());
185
186 let (line, end_line) = sig_by_name
187 .get(export.as_str())
188 .and_then(|s| s.start_line.zip(s.end_line))
189 .unwrap_or((0, 0));
190
191 file_symbols.push(SymbolDef {
192 name: export.clone(),
193 kind: "export".to_string(),
194 file: file_path.clone(),
195 line,
196 end_line,
197 is_exported: true,
198 signature,
199 });
200 }
201 }
202
203 file_symbols.sort_by_key(|s| s.line);
204
205 if !file_symbols.is_empty() {
206 result.insert(file_path.clone(), file_symbols);
207 }
208 }
209
210 result
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
221 fn ambiguous_calls_are_not_linked_by_name() {
222 use crate::core::call_graph::{CallEdge, SymbolSpan};
223 let span = |file: &str, name: &str| SymbolSpan {
224 file: file.into(),
225 name: name.into(),
226 start_line: 1,
227 end_line: 3,
228 kind: "fn".into(),
229 };
230 let inputs = CallGraphInputs {
231 project_root: "/tmp".into(),
232 file_paths: vec!["a.rs".into(), "b.rs".into(), "app.rs".into()],
233 symbols: vec![
234 span("a.rs", "save"),
235 span("b.rs", "save"),
236 span("b.rs", "load"),
237 ],
238 import_edges: Vec::new(),
239 };
240 let call = |callee: &str| CallEdge {
241 caller_file: "app.rs".into(),
242 caller_symbol: "run".into(),
243 caller_line: 2,
244 callee_name: callee.into(),
245 ..Default::default()
246 };
247 let mut call_graph = CallGraph::new("/tmp");
248 call_graph.edges = vec![call("save"), call("load")];
249
250 assert_eq!(
251 structural_call_pairs(&inputs, &call_graph),
252 [("app.rs".to_string(), "b.rs".to_string())],
253 "only the unique `load` binds"
254 );
255 }
256
257 #[test]
260 fn empty_graph_calls_count_only_after_a_semantic_answer() {
261 use crate::core::property_graph::{
262 CachedResolution, CodeGraph, GRAPH_ENGINE_VERSION, PropertyGraphMetaV1, write_meta,
263 };
264 let _iso = crate::core::data_dir::isolated_data_dir();
265 let proj = tempfile::tempdir().unwrap();
266 let root = proj.path().to_str().unwrap();
267 let meta = PropertyGraphMetaV1 {
268 built_at: "2026-01-01T00:00:00Z".to_string(),
269 engine_version: GRAPH_ENGINE_VERSION,
270 ..Default::default()
271 };
272 write_meta(root, &meta).unwrap();
273 let graph = CodeGraph::open(root).unwrap();
274 assert_eq!(graph_call_pairs(root), None, "not consolidated yet");
275
276 let veto = CachedResolution {
277 backend: "lsp:test@1".into(),
278 outcome: "external".into(),
279 target_file: None,
280 target_line: None,
281 target_symbol: None,
282 context: String::new(),
283 };
284 let admission = crate::core::context_admission::stores::StoreAdmission::current();
285 graph
286 .semantic_store(("app.rs", 2, 4, "definition"), "h", &veto, &admission)
287 .unwrap();
288 assert_eq!(graph_call_pairs(root), Some(Vec::new()), "vetoed stays out");
289 }
290
291 #[test]
292 fn repo_graph_deduplicates_edges() {
293 let mut index = ProjectIndex::new("/tmp");
294 index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
295 index.files.insert("b.rs".into(), dummy_file_entry("b.rs"));
296 index.edges.push(graph_index::IndexEdge {
297 from: "a.rs".into(),
298 to: "b.rs".into(),
299 kind: "import".into(),
300 weight: 1.0,
301 });
302 index.edges.push(graph_index::IndexEdge {
303 from: "a.rs".into(),
304 to: "b.rs".into(),
305 kind: "import".into(),
306 weight: 1.0,
307 });
308
309 let graph = RepoGraph::from_index_and_calls(&index, &[], &HashMap::new());
310
311 let a_deps = graph.forward.get("a.rs").unwrap();
312 assert_eq!(a_deps.len(), 1, "duplicate edges should be deduped");
313 }
314
315 #[test]
316 fn repo_graph_ignores_self_edges() {
317 let mut index = ProjectIndex::new("/tmp");
318 index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
319 index.edges.push(graph_index::IndexEdge {
320 from: "a.rs".into(),
321 to: "a.rs".into(),
322 kind: "import".into(),
323 weight: 1.0,
324 });
325
326 let graph = RepoGraph::from_index_and_calls(&index, &[], &HashMap::new());
327
328 assert!(
329 !graph.forward.contains_key("a.rs"),
330 "self-edges should be excluded"
331 );
332 }
333
334 fn dummy_file_entry(path: &str) -> graph_index::FileEntry {
335 graph_index::FileEntry {
336 path: path.into(),
337 hash: "abc".into(),
338 language: "rust".into(),
339 line_count: 10,
340 token_count: 50,
341 exports: vec![],
342 summary: String::new(),
343 }
344 }
345}