1use crate::core::graph_index::IndexEdge;
11
12#[derive(Debug, Clone, serde::Serialize)]
14pub struct CrossSourceHint {
15 pub source_uri: String,
16 pub relation: String,
17 pub weight: f32,
18}
19
20pub fn hints_for_file(
25 file_path: &str,
26 edges: &[IndexEdge],
27 project_root: &str,
28) -> Vec<CrossSourceHint> {
29 hints_for_file_matching(file_path, edges, project_root, |_| true)
30}
31
32pub(crate) fn hints_for_file_matching(
37 file_path: &str,
38 edges: &[IndexEdge],
39 project_root: &str,
40 mut include: impl FnMut(&CrossSourceHint) -> bool,
41) -> Vec<CrossSourceHint> {
42 let rel = crate::core::graph_index::graph_relative_key(file_path, project_root);
43
44 let matches_path = |edge_path: &str| -> bool { edge_path == file_path || edge_path == rel };
45
46 let mut hints: Vec<CrossSourceHint> = edges
47 .iter()
48 .filter(|e| {
49 (matches_path(&e.from) && is_external_uri(&e.to))
50 || (matches_path(&e.to) && is_external_uri(&e.from))
51 })
52 .map(|e| {
53 if matches_path(&e.from) {
54 CrossSourceHint {
55 source_uri: e.to.clone(),
56 relation: e.kind.clone(),
57 weight: e.weight,
58 }
59 } else {
60 CrossSourceHint {
61 source_uri: e.from.clone(),
62 relation: e.kind.clone(),
63 weight: e.weight,
64 }
65 }
66 })
67 .collect();
68
69 hints.retain(|hint| include(hint));
70 hints.sort_by(|a, b| {
71 b.weight
72 .partial_cmp(&a.weight)
73 .unwrap_or(std::cmp::Ordering::Equal)
74 });
75 hints.dedup_by(|a, b| a.source_uri == b.source_uri);
76 hints.truncate(5);
77 hints
78}
79
80pub fn format_hints(hints: &[CrossSourceHint]) -> String {
82 if hints.is_empty() {
83 return String::new();
84 }
85
86 let mut out = String::from("\n--- Cross-Source Hints ---\n");
87 for hint in hints {
88 out.push_str(&format!(
89 " {} [{}] w={:.1}\n",
90 hint.source_uri, hint.relation, hint.weight
91 ));
92 }
93 out
94}
95
96fn is_external_uri(path: &str) -> bool {
97 path.contains("://")
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::core::graph_index::IndexEdge;
104
105 fn edge(from: &str, to: &str, kind: &str, weight: f32) -> IndexEdge {
106 IndexEdge {
107 from: from.into(),
108 to: to.into(),
109 kind: kind.into(),
110 weight,
111 }
112 }
113
114 const ROOT: &str = "/project";
115
116 #[test]
117 fn finds_hints_from_forward_edges() {
118 let edges = vec![
119 edge("src/auth.rs", "github://issues/42", "mentions", 1.0),
120 edge("src/auth.rs", "postgres://schemas/sessions", "queries", 1.2),
121 ];
122
123 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
124 assert_eq!(hints.len(), 2);
125 assert!(hints.iter().any(|h| h.source_uri.contains("issues/42")));
126 assert!(
127 hints
128 .iter()
129 .any(|h| h.source_uri.contains("schemas/sessions"))
130 );
131 }
132
133 #[test]
134 fn finds_hints_from_reverse_edges() {
135 let edges = vec![edge(
136 "github://issues/42",
137 "src/auth.rs",
138 "mentioned_in",
139 0.8,
140 )];
141
142 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
143 assert_eq!(hints.len(), 1);
144 assert!(hints[0].source_uri.contains("issues/42"));
145 }
146
147 #[test]
148 fn finds_hints_with_absolute_path() {
149 let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
150 let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
151 assert_eq!(hints.len(), 1, "absolute path should match relative edge");
152 }
153
154 #[test]
155 fn ignores_code_to_code_edges() {
156 let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
157
158 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
159 assert!(hints.is_empty());
160 }
161
162 #[test]
163 fn deduplicates_and_limits_to_5() {
164 let edges: Vec<IndexEdge> = (0..10)
165 .map(|i| {
166 edge(
167 "src/auth.rs",
168 &format!("github://issues/{i}"),
169 "mentions",
170 1.0,
171 )
172 })
173 .collect();
174
175 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
176 assert_eq!(hints.len(), 5);
177 }
178
179 #[test]
180 fn range_filter_runs_before_output_limit() {
181 let mut edges: Vec<IndexEdge> = (0..5)
182 .map(|i| {
183 edge(
184 "src/auth.rs",
185 &format!("health://complexity/src/auth.rs#irrelevant_{i}"),
186 "health_hotspot",
187 10.0,
188 )
189 })
190 .collect();
191 edges.push(edge(
192 "src/auth.rs",
193 "health://complexity/src/auth.rs#requested",
194 "health_hotspot",
195 1.0,
196 ));
197
198 let hints = hints_for_file_matching("src/auth.rs", &edges, ROOT, |hint| {
199 hint.source_uri.ends_with("#requested")
200 });
201 assert_eq!(hints.len(), 1);
202 assert!(hints[0].source_uri.ends_with("#requested"));
203 }
204
205 #[test]
206 fn sorts_by_weight_descending() {
207 let edges = vec![
208 edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
209 edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
210 edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
211 ];
212
213 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
214 assert_eq!(hints[0].source_uri, "github://issues/2");
215 assert_eq!(hints[1].source_uri, "github://issues/3");
216 assert_eq!(hints[2].source_uri, "github://issues/1");
217 }
218
219 #[test]
220 fn format_hints_empty_returns_empty() {
221 assert!(format_hints(&[]).is_empty());
222 }
223
224 #[test]
225 fn format_hints_produces_readable_output() {
226 let hints = vec![CrossSourceHint {
227 source_uri: "github://issues/42".into(),
228 relation: "mentions".into(),
229 weight: 1.0,
230 }];
231
232 let output = format_hints(&hints);
233 assert!(output.contains("Cross-Source Hints"));
234 assert!(output.contains("github://issues/42"));
235 assert!(output.contains("[mentions]"));
236 }
237}