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