1use crate::core::graph_index::IndexEdge;
11
12const DIRECTORY_MATCH_FACTOR: f32 = 0.9;
15
16#[derive(Debug, Clone, serde::Serialize)]
18pub struct CrossSourceHint {
19 pub source_uri: String,
20 pub relation: String,
21 pub weight: f32,
22}
23
24pub fn hints_for_file(
29 file_path: &str,
30 edges: &[IndexEdge],
31 project_root: &str,
32) -> Vec<CrossSourceHint> {
33 hints_for_file_matching(file_path, edges, project_root, |_| true)
34}
35
36pub fn hints_for_file_matching(
41 file_path: &str,
42 edges: &[IndexEdge],
43 project_root: &str,
44 mut include: impl FnMut(&CrossSourceHint) -> bool,
45) -> Vec<CrossSourceHint> {
46 let rel = crate::core::graph_index::graph_relative_key(file_path, project_root);
47
48 let path_weight = |edge_path: &str| -> Option<f32> {
52 if edge_path == file_path || edge_path == rel {
53 Some(1.0)
54 } else if !edge_path.is_empty()
55 && !edge_path.contains("://")
56 && rel
58 .replace('\\', "/")
59 .strip_prefix(edge_path.replace('\\', "/").as_str())
60 .is_some_and(|rest| rest.starts_with('/'))
61 {
62 Some(DIRECTORY_MATCH_FACTOR)
63 } else {
64 None
65 }
66 };
67
68 let mut hints: Vec<CrossSourceHint> = edges
69 .iter()
70 .filter_map(|e| {
71 if is_external_uri(&e.to)
72 && let Some(factor) = path_weight(&e.from)
73 {
74 Some(CrossSourceHint {
75 source_uri: e.to.clone(),
76 relation: e.kind.clone(),
77 weight: e.weight * factor,
78 })
79 } else if is_external_uri(&e.from)
80 && let Some(factor) = path_weight(&e.to)
81 {
82 Some(CrossSourceHint {
83 source_uri: e.from.clone(),
84 relation: e.kind.clone(),
85 weight: e.weight * factor,
86 })
87 } else {
88 None
89 }
90 })
91 .collect();
92
93 hints.retain(|hint| include(hint));
94 hints.sort_by(|a, b| {
97 b.weight
98 .partial_cmp(&a.weight)
99 .unwrap_or(std::cmp::Ordering::Equal)
100 .then_with(|| a.source_uri.cmp(&b.source_uri))
101 .then_with(|| a.relation.cmp(&b.relation))
102 });
103 let mut seen = std::collections::HashSet::new();
107 hints.retain(|hint| seen.insert(hint.source_uri.clone()));
108 hints.truncate(5);
109 hints
110}
111
112pub fn format_hints(hints: &[CrossSourceHint]) -> String {
114 if hints.is_empty() {
115 return String::new();
116 }
117
118 let mut out = String::from("\n--- Cross-Source Hints ---\n");
119 for hint in hints {
120 out.push_str(&format!(
121 " {} [{}] w={:.1}\n",
122 hint.source_uri, hint.relation, hint.weight
123 ));
124 }
125 out
126}
127
128fn is_external_uri(path: &str) -> bool {
129 path.contains("://")
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::core::graph_index::IndexEdge;
136
137 fn edge(from: &str, to: &str, kind: &str, weight: f32) -> IndexEdge {
138 IndexEdge {
139 from: from.into(),
140 to: to.into(),
141 kind: kind.into(),
142 weight,
143 }
144 }
145
146 const ROOT: &str = "/project";
147
148 #[test]
149 fn a_directory_reference_hints_files_below_it_after_exact_ones() {
150 let edges = vec![
151 edge("emspace://pages/core", "src/core", "documents", 1.0),
152 edge(
153 "emspace://pages/core/auth",
154 "src/core/auth.rs",
155 "documents",
156 1.0,
157 ),
158 edge("emspace://pages/corelib", "src/corelib", "documents", 1.0),
159 ];
160
161 let hints = hints_for_file("src/core/auth.rs", &edges, ROOT);
162 let uris: Vec<&str> = hints.iter().map(|h| h.source_uri.as_str()).collect();
163 assert_eq!(
165 uris,
166 vec!["emspace://pages/core/auth", "emspace://pages/core"]
167 );
168 assert_eq!(hints_for_file("src/core", &edges, ROOT).len(), 1);
169 }
170
171 #[test]
172 fn finds_hints_from_forward_edges() {
173 let edges = vec![
174 edge("src/auth.rs", "github://issues/42", "mentions", 1.0),
175 edge("src/auth.rs", "postgres://schemas/sessions", "queries", 1.2),
176 ];
177
178 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
179 assert_eq!(hints.len(), 2);
180 assert!(hints.iter().any(|h| h.source_uri.contains("issues/42")));
181 assert!(
182 hints
183 .iter()
184 .any(|h| h.source_uri.contains("schemas/sessions"))
185 );
186 }
187
188 #[test]
189 fn finds_hints_from_reverse_edges() {
190 let edges = vec![edge(
191 "github://issues/42",
192 "src/auth.rs",
193 "mentioned_in",
194 0.8,
195 )];
196
197 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
198 assert_eq!(hints.len(), 1);
199 assert!(hints[0].source_uri.contains("issues/42"));
200 }
201
202 #[test]
203 fn finds_hints_with_absolute_path() {
204 let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
205 let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
206 assert_eq!(hints.len(), 1, "absolute path should match relative edge");
207 }
208
209 #[test]
210 fn ignores_code_to_code_edges() {
211 let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
212
213 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
214 assert!(hints.is_empty());
215 }
216
217 #[test]
218 fn deduplicates_and_limits_to_5() {
219 let edges: Vec<IndexEdge> = (0..10)
220 .map(|i| {
221 edge(
222 "src/auth.rs",
223 &format!("github://issues/{i}"),
224 "mentions",
225 1.0,
226 )
227 })
228 .collect();
229
230 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
231 assert_eq!(hints.len(), 5);
232 }
233
234 #[test]
235 fn hint_output_is_byte_stable_for_the_same_graph_in_any_row_order() {
236 let edges = vec![
237 edge("emspace://pages/core", "src/a.rs", "documents", 0.8),
238 edge("src/a.rs", "emspace://pages/core", "mentioned_in", 0.64),
239 edge("github://issues/9", "src/a.rs", "mentions", 0.8),
240 edge("github://issues/3", "src/a.rs", "mentions", 0.8),
241 edge("emspace://pages/src", "src", "documents", 0.8),
242 edge("jira://tickets/A-1", "src/a.rs", "mentions", 0.5),
243 ];
244 let expected = format_hints(&hints_for_file("src/a.rs", &edges, ROOT));
245 assert!(expected.contains("emspace://pages/core [documents]"));
246 assert_eq!(expected.matches("emspace://pages/core ").count(), 1);
247 let mut rotated = edges.clone();
248 for _ in 0..edges.len() {
249 rotated.rotate_left(1);
250 let mut reversed = rotated.clone();
251 reversed.reverse();
252 for order in [&rotated, &reversed] {
253 assert_eq!(
254 format_hints(&hints_for_file("src/a.rs", order, ROOT)),
255 expected
256 );
257 }
258 }
259 }
260
261 #[test]
262 fn a_source_linked_both_ways_is_hinted_once_at_its_strongest_relation() {
263 let edges = vec![
264 edge("emspace://pages/core", "src/a.rs", "documents", 0.8),
265 edge("github://issues/7", "src/a.rs", "mentions", 0.7),
266 edge("src/a.rs", "emspace://pages/core", "mentioned_in", 0.64),
267 ];
268 let hints = hints_for_file("src/a.rs", &edges, ROOT);
269 let seen: Vec<(&str, &str)> = hints
270 .iter()
271 .map(|h| (h.source_uri.as_str(), h.relation.as_str()))
272 .collect();
273 assert_eq!(
274 seen,
275 vec![
276 ("emspace://pages/core", "documents"),
277 ("github://issues/7", "mentions")
278 ]
279 );
280 }
281
282 #[test]
283 fn range_filter_runs_before_output_limit() {
284 let mut edges: Vec<IndexEdge> = (0..5)
285 .map(|i| {
286 edge(
287 "src/auth.rs",
288 &format!("health://complexity/src/auth.rs#irrelevant_{i}"),
289 "health_hotspot",
290 10.0,
291 )
292 })
293 .collect();
294 edges.push(edge(
295 "src/auth.rs",
296 "health://complexity/src/auth.rs#requested",
297 "health_hotspot",
298 1.0,
299 ));
300
301 let hints = hints_for_file_matching("src/auth.rs", &edges, ROOT, |hint| {
302 hint.source_uri.ends_with("#requested")
303 });
304 assert_eq!(hints.len(), 1);
305 assert!(hints[0].source_uri.ends_with("#requested"));
306 }
307
308 #[test]
309 fn sorts_by_weight_descending() {
310 let edges = vec![
311 edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
312 edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
313 edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
314 ];
315
316 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
317 assert_eq!(hints[0].source_uri, "github://issues/2");
318 assert_eq!(hints[1].source_uri, "github://issues/3");
319 assert_eq!(hints[2].source_uri, "github://issues/1");
320 }
321
322 #[test]
323 fn format_hints_empty_returns_empty() {
324 {
325 let found = &format_hints(&[]);
326 assert!(found.is_empty(), "{found:?}");
327 }
328 }
329
330 #[test]
331 fn format_hints_produces_readable_output() {
332 let hints = vec![CrossSourceHint {
333 source_uri: "github://issues/42".into(),
334 relation: "mentions".into(),
335 weight: 1.0,
336 }];
337
338 let output = format_hints(&hints);
339 assert!(output.contains("Cross-Source Hints"));
340 assert!(output.contains("github://issues/42"));
341 assert!(output.contains("[mentions]"));
342 }
343}