1use std::collections::{HashMap, HashSet};
7
8use crate::core::call_graph::CallGraph;
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 call_graph = CallGraph::load_or_build(project_root, &index);
40
41 Self::from_index_and_calls(&index, &call_graph, &content_cache)
42 }
43
44 fn from_index_and_calls(
45 index: &ProjectIndex,
46 call_graph: &CallGraph,
47 content_cache: &HashMap<String, String>,
48 ) -> Self {
49 let files: HashSet<String> = index.files.keys().cloned().collect();
50
51 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
52
53 for edge in &index.edges {
55 if files.contains(&edge.from) && files.contains(&edge.to) && edge.from != edge.to {
56 forward
57 .entry(edge.from.clone())
58 .or_default()
59 .push(edge.to.clone());
60 }
61 }
62
63 let symbols_by_name = build_symbol_location_map(index);
65 for call_edge in &call_graph.edges {
66 if let Some(target_file) = symbols_by_name.get(&call_edge.callee_name.to_lowercase()) {
67 if files.contains(&call_edge.caller_file)
68 && files.contains(target_file)
69 && call_edge.caller_file != *target_file
70 {
71 forward
72 .entry(call_edge.caller_file.clone())
73 .or_default()
74 .push(target_file.clone());
75 }
76 }
77 }
78
79 for deps in forward.values_mut() {
81 deps.sort();
82 deps.dedup();
83 }
84
85 let symbols_by_file = build_symbols_with_signatures(index, content_cache);
86
87 Self {
88 files,
89 forward,
90 symbols_by_file,
91 }
92 }
93}
94
95fn build_symbol_location_map(index: &ProjectIndex) -> HashMap<String, String> {
97 let mut map: HashMap<String, String> = HashMap::with_capacity(index.symbols.len());
98 for sym in index.symbols.values() {
99 map.entry(sym.name.to_lowercase())
100 .or_insert_with(|| sym.file.clone());
101 }
102 map
103}
104
105fn build_symbols_with_signatures(
107 index: &ProjectIndex,
108 content_cache: &HashMap<String, String>,
109) -> HashMap<String, Vec<SymbolDef>> {
110 let mut result: HashMap<String, Vec<SymbolDef>> = HashMap::new();
111
112 let mut idx_symbols: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
114 for sym in index.symbols.values() {
115 idx_symbols.entry(sym.file.as_str()).or_default().push(sym);
116 }
117
118 for (file_path, file_entry) in &index.files {
119 let ext = std::path::Path::new(file_path)
120 .extension()
121 .and_then(|e| e.to_str())
122 .unwrap_or("");
123
124 let signatures = content_cache
126 .get(file_path)
127 .map(|content| crate::core::signatures::extract_signatures(content, ext))
128 .unwrap_or_default();
129
130 let sig_by_name: HashMap<&str, &crate::core::signatures::Signature> =
131 signatures.iter().map(|s| (s.name.as_str(), s)).collect();
132
133 let mut file_symbols: Vec<SymbolDef> = Vec::new();
134
135 if let Some(syms) = idx_symbols.get(file_path.as_str()) {
136 for sym in syms {
137 let signature = sig_by_name
138 .get(sym.name.as_str())
139 .map_or_else(|| format!("{} {}", sym.kind, sym.name), |s| s.to_compact());
140
141 file_symbols.push(SymbolDef {
142 name: sym.name.clone(),
143 kind: sym.kind.clone(),
144 file: sym.file.clone(),
145 line: sym.start_line,
146 end_line: sym.end_line,
147 is_exported: sym.is_exported,
148 signature,
149 });
150 }
151 }
152
153 for export in &file_entry.exports {
155 let already_present = file_symbols.iter().any(|s| s.name == *export);
156 if !already_present {
157 let signature = sig_by_name
158 .get(export.as_str())
159 .map_or_else(|| export.clone(), |s| s.to_compact());
160
161 let (line, end_line) = sig_by_name
162 .get(export.as_str())
163 .and_then(|s| s.start_line.zip(s.end_line))
164 .unwrap_or((0, 0));
165
166 file_symbols.push(SymbolDef {
167 name: export.clone(),
168 kind: "export".to_string(),
169 file: file_path.clone(),
170 line,
171 end_line,
172 is_exported: true,
173 signature,
174 });
175 }
176 }
177
178 file_symbols.sort_by_key(|s| s.line);
179
180 if !file_symbols.is_empty() {
181 result.insert(file_path.clone(), file_symbols);
182 }
183 }
184
185 result
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn symbol_location_map_uses_first_definition() {
194 let mut index = ProjectIndex::new("/tmp");
195 index.symbols.insert(
196 "a::foo".into(),
197 SymbolEntry {
198 file: "a.rs".into(),
199 name: "foo".into(),
200 kind: "fn".into(),
201 start_line: 1,
202 end_line: 10,
203 is_exported: true,
204 },
205 );
206 index.symbols.insert(
207 "b::foo".into(),
208 SymbolEntry {
209 file: "b.rs".into(),
210 name: "foo".into(),
211 kind: "fn".into(),
212 start_line: 1,
213 end_line: 5,
214 is_exported: false,
215 },
216 );
217
218 let map = build_symbol_location_map(&index);
219 assert!(map.contains_key("foo"));
220 }
221
222 #[test]
223 fn repo_graph_deduplicates_edges() {
224 let mut index = ProjectIndex::new("/tmp");
225 index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
226 index.files.insert("b.rs".into(), dummy_file_entry("b.rs"));
227 index.edges.push(graph_index::IndexEdge {
228 from: "a.rs".into(),
229 to: "b.rs".into(),
230 kind: "import".into(),
231 weight: 1.0,
232 });
233 index.edges.push(graph_index::IndexEdge {
234 from: "a.rs".into(),
235 to: "b.rs".into(),
236 kind: "import".into(),
237 weight: 1.0,
238 });
239
240 let call_graph = CallGraph::new("/tmp");
241 let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
242
243 let a_deps = graph.forward.get("a.rs").unwrap();
244 assert_eq!(a_deps.len(), 1, "duplicate edges should be deduped");
245 }
246
247 #[test]
248 fn repo_graph_ignores_self_edges() {
249 let mut index = ProjectIndex::new("/tmp");
250 index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
251 index.edges.push(graph_index::IndexEdge {
252 from: "a.rs".into(),
253 to: "a.rs".into(),
254 kind: "import".into(),
255 weight: 1.0,
256 });
257
258 let call_graph = CallGraph::new("/tmp");
259 let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
260
261 assert!(
262 !graph.forward.contains_key("a.rs"),
263 "self-edges should be excluded"
264 );
265 }
266
267 fn dummy_file_entry(path: &str) -> graph_index::FileEntry {
268 graph_index::FileEntry {
269 path: path.into(),
270 hash: "abc".into(),
271 language: "rust".into(),
272 line_count: 10,
273 token_count: 50,
274 exports: vec![],
275 summary: String::new(),
276 }
277 }
278}