1use crate::core::content_chunk::ContentChunk;
16use crate::core::graph_index::IndexEdge;
17
18pub const EDGE_MENTIONS: &str = "mentions";
20pub const EDGE_QUERIES: &str = "queries";
21pub const EDGE_DOCUMENTS: &str = "documents";
22pub const EDGE_RESOLVES: &str = "resolves";
23
24pub fn extract_cross_source_edges(chunks: &[ContentChunk]) -> Vec<IndexEdge> {
29 let mut edges = Vec::new();
30
31 for chunk in chunks {
32 if !chunk.is_external() || chunk.references.is_empty() {
33 continue;
34 }
35
36 let edge_kind = chunk_to_edge_kind(chunk);
37
38 for ref_path in &chunk.references {
39 edges.push(IndexEdge {
40 from: chunk.file_path.clone(),
41 to: ref_path.clone(),
42 kind: edge_kind.to_string(),
43 weight: edge_weight_for_kind(edge_kind),
44 });
45
46 edges.push(IndexEdge {
47 from: ref_path.clone(),
48 to: chunk.file_path.clone(),
49 kind: "mentioned_in".to_string(),
50 weight: edge_weight_for_kind(edge_kind) * 0.8,
51 });
52 }
53 }
54
55 edges
56}
57
58fn chunk_to_edge_kind(chunk: &ContentChunk) -> &'static str {
60 use crate::core::bm25_index::ChunkKind;
61 match chunk.kind {
62 ChunkKind::PullRequest => EDGE_RESOLVES,
63 ChunkKind::WikiPage => EDGE_DOCUMENTS,
64 ChunkKind::DbSchema => EDGE_QUERIES,
65 _ => EDGE_MENTIONS,
66 }
67}
68
69fn edge_weight_for_kind(kind: &str) -> f32 {
72 match kind {
73 EDGE_RESOLVES => 1.5,
74 EDGE_QUERIES => 1.2,
75 EDGE_DOCUMENTS => 0.8,
76 _ => 1.0,
77 }
78}
79
80pub fn merge_edges(existing: &mut Vec<IndexEdge>, new_edges: Vec<IndexEdge>) -> usize {
84 let mut added = 0usize;
85 for edge in new_edges {
86 let duplicate = existing
87 .iter_mut()
88 .find(|e| e.from == edge.from && e.to == edge.to && e.kind == edge.kind);
89
90 if let Some(existing_edge) = duplicate {
91 if edge.weight > existing_edge.weight {
92 existing_edge.weight = edge.weight;
93 }
94 } else {
95 existing.push(edge);
96 added += 1;
97 }
98 }
99 added
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::core::bm25_index::ChunkKind;
106 use crate::core::content_chunk::ContentChunk;
107
108 fn issue_chunk(id: &str, refs: Vec<&str>) -> ContentChunk {
109 ContentChunk::from_provider(
110 "github",
111 "issues",
112 id,
113 &format!("Issue #{id}"),
114 ChunkKind::Issue,
115 format!("Body of issue #{id}"),
116 refs.into_iter().map(String::from).collect(),
117 None,
118 )
119 }
120
121 fn pr_chunk(id: &str, refs: Vec<&str>) -> ContentChunk {
122 ContentChunk::from_provider(
123 "github",
124 "pull_requests",
125 id,
126 &format!("PR #{id}"),
127 ChunkKind::PullRequest,
128 format!("PR #{id} fixes auth"),
129 refs.into_iter().map(String::from).collect(),
130 None,
131 )
132 }
133
134 fn wiki_chunk(id: &str, refs: Vec<&str>) -> ContentChunk {
135 ContentChunk::from_provider(
136 "confluence",
137 "wikis",
138 id,
139 &format!("Wiki {id}"),
140 ChunkKind::WikiPage,
141 format!("Documentation for {id}"),
142 refs.into_iter().map(String::from).collect(),
143 None,
144 )
145 }
146
147 #[test]
148 fn issue_creates_mentions_edges() {
149 let chunks = vec![issue_chunk("42", vec!["src/auth.rs", "src/db.rs"])];
150 let edges = extract_cross_source_edges(&chunks);
151
152 assert_eq!(edges.len(), 4); assert!(edges.iter().any(|e| e.from.contains("issues/42")
154 && e.to == "src/auth.rs"
155 && e.kind == EDGE_MENTIONS));
156 assert!(edges.iter().any(|e| e.from == "src/auth.rs"
157 && e.to.contains("issues/42")
158 && e.kind == "mentioned_in"));
159 }
160
161 #[test]
162 fn pr_creates_resolves_edges() {
163 let chunks = vec![pr_chunk("10", vec!["src/handler.rs"])];
164 let edges = extract_cross_source_edges(&chunks);
165
166 assert!(edges.iter().any(|e| e.kind == EDGE_RESOLVES));
167 assert_eq!(
168 edges
169 .iter()
170 .find(|e| e.kind == EDGE_RESOLVES)
171 .unwrap()
172 .weight,
173 1.5
174 );
175 }
176
177 #[test]
178 fn wiki_creates_documents_edges() {
179 let chunks = vec![wiki_chunk("auth-guide", vec!["src/auth/mod.rs"])];
180 let edges = extract_cross_source_edges(&chunks);
181
182 assert!(edges.iter().any(|e| e.kind == EDGE_DOCUMENTS));
183 }
184
185 #[test]
186 fn no_edges_for_file_source_chunks() {
187 let code_chunk = ContentChunk::from(crate::core::bm25_index::CodeChunk {
188 input_origin: None,
189 file_path: "src/main.rs".into(),
190 symbol_name: "main".into(),
191 kind: ChunkKind::Function,
192 start_line: 1,
193 end_line: 10,
194 content: "fn main() {}".into(),
195 tokens: vec![],
196 token_count: 0,
197 });
198 let edges = extract_cross_source_edges(&[code_chunk]);
199 assert!(edges.is_empty(), "{edges:?}");
200 }
201
202 #[test]
203 fn no_edges_for_chunks_without_references() {
204 let chunk = ContentChunk::from_provider(
205 "github",
206 "issues",
207 "1",
208 "Title",
209 ChunkKind::Issue,
210 "No file refs".into(),
211 vec![],
212 None,
213 );
214 let edges = extract_cross_source_edges(&[chunk]);
215 assert!(edges.is_empty(), "{edges:?}");
216 }
217
218 #[test]
219 fn merge_edges_deduplicates() {
220 let mut existing = vec![IndexEdge {
221 from: "a".into(),
222 to: "b".into(),
223 kind: EDGE_MENTIONS.into(),
224 weight: 1.0,
225 }];
226
227 let new = vec![
228 IndexEdge {
229 from: "a".into(),
230 to: "b".into(),
231 kind: EDGE_MENTIONS.into(),
232 weight: 0.5, },
234 IndexEdge {
235 from: "a".into(),
236 to: "c".into(),
237 kind: EDGE_MENTIONS.into(),
238 weight: 1.0,
239 },
240 ];
241
242 let added = merge_edges(&mut existing, new);
243 assert_eq!(added, 1);
244 assert_eq!(existing.len(), 2);
245 assert_eq!(existing.iter().find(|e| e.to == "b").unwrap().weight, 1.0);
246 }
247
248 #[test]
249 fn merge_edges_upgrades_weight() {
250 let mut existing = vec![IndexEdge {
251 from: "a".into(),
252 to: "b".into(),
253 kind: EDGE_MENTIONS.into(),
254 weight: 0.5,
255 }];
256
257 let new = vec![IndexEdge {
258 from: "a".into(),
259 to: "b".into(),
260 kind: EDGE_MENTIONS.into(),
261 weight: 2.0,
262 }];
263
264 merge_edges(&mut existing, new);
265 assert_eq!(existing[0].weight, 2.0);
266 }
267
268 #[test]
269 fn multiple_issues_referencing_same_file_creates_hub() {
270 let chunks = vec![
271 issue_chunk("1", vec!["src/auth.rs"]),
272 issue_chunk("2", vec!["src/auth.rs"]),
273 issue_chunk("3", vec!["src/auth.rs"]),
274 ];
275
276 let edges = extract_cross_source_edges(&chunks);
277 let auth_incoming = edges
278 .iter()
279 .filter(|e| e.to == "src/auth.rs" && e.kind == EDGE_MENTIONS)
280 .count();
281 assert_eq!(auth_incoming, 3);
282 }
283}