lean_ctx/core/
cross_source_hints.rs1use 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 let rel = crate::core::graph_index::graph_relative_key(file_path, project_root);
30
31 let matches_path = |edge_path: &str| -> bool { edge_path == file_path || edge_path == rel };
32
33 let mut hints: Vec<CrossSourceHint> = edges
34 .iter()
35 .filter(|e| {
36 (matches_path(&e.from) && is_external_uri(&e.to))
37 || (matches_path(&e.to) && is_external_uri(&e.from))
38 })
39 .map(|e| {
40 if matches_path(&e.from) {
41 CrossSourceHint {
42 source_uri: e.to.clone(),
43 relation: e.kind.clone(),
44 weight: e.weight,
45 }
46 } else {
47 CrossSourceHint {
48 source_uri: e.from.clone(),
49 relation: e.kind.clone(),
50 weight: e.weight,
51 }
52 }
53 })
54 .collect();
55
56 hints.sort_by(|a, b| {
57 b.weight
58 .partial_cmp(&a.weight)
59 .unwrap_or(std::cmp::Ordering::Equal)
60 });
61 hints.dedup_by(|a, b| a.source_uri == b.source_uri);
62 hints.truncate(5);
63 hints
64}
65
66pub fn format_hints(hints: &[CrossSourceHint]) -> String {
68 if hints.is_empty() {
69 return String::new();
70 }
71
72 let mut out = String::from("\n--- Cross-Source Hints ---\n");
73 for hint in hints {
74 out.push_str(&format!(
75 " {} [{}] w={:.1}\n",
76 hint.source_uri, hint.relation, hint.weight
77 ));
78 }
79 out
80}
81
82fn is_external_uri(path: &str) -> bool {
83 path.contains("://")
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use crate::core::graph_index::IndexEdge;
90
91 fn edge(from: &str, to: &str, kind: &str, weight: f32) -> IndexEdge {
92 IndexEdge {
93 from: from.into(),
94 to: to.into(),
95 kind: kind.into(),
96 weight,
97 }
98 }
99
100 const ROOT: &str = "/project";
101
102 #[test]
103 fn finds_hints_from_forward_edges() {
104 let edges = vec![
105 edge("src/auth.rs", "github://issues/42", "mentions", 1.0),
106 edge("src/auth.rs", "postgres://schemas/sessions", "queries", 1.2),
107 ];
108
109 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
110 assert_eq!(hints.len(), 2);
111 assert!(hints.iter().any(|h| h.source_uri.contains("issues/42")));
112 assert!(hints
113 .iter()
114 .any(|h| h.source_uri.contains("schemas/sessions")));
115 }
116
117 #[test]
118 fn finds_hints_from_reverse_edges() {
119 let edges = vec![edge(
120 "github://issues/42",
121 "src/auth.rs",
122 "mentioned_in",
123 0.8,
124 )];
125
126 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
127 assert_eq!(hints.len(), 1);
128 assert!(hints[0].source_uri.contains("issues/42"));
129 }
130
131 #[test]
132 fn finds_hints_with_absolute_path() {
133 let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
134 let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
135 assert_eq!(hints.len(), 1, "absolute path should match relative edge");
136 }
137
138 #[test]
139 fn ignores_code_to_code_edges() {
140 let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
141
142 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
143 assert!(hints.is_empty());
144 }
145
146 #[test]
147 fn deduplicates_and_limits_to_5() {
148 let edges: Vec<IndexEdge> = (0..10)
149 .map(|i| {
150 edge(
151 "src/auth.rs",
152 &format!("github://issues/{i}"),
153 "mentions",
154 1.0,
155 )
156 })
157 .collect();
158
159 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
160 assert_eq!(hints.len(), 5);
161 }
162
163 #[test]
164 fn sorts_by_weight_descending() {
165 let edges = vec![
166 edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
167 edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
168 edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
169 ];
170
171 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
172 assert_eq!(hints[0].source_uri, "github://issues/2");
173 assert_eq!(hints[1].source_uri, "github://issues/3");
174 assert_eq!(hints[2].source_uri, "github://issues/1");
175 }
176
177 #[test]
178 fn format_hints_empty_returns_empty() {
179 assert!(format_hints(&[]).is_empty());
180 }
181
182 #[test]
183 fn format_hints_produces_readable_output() {
184 let hints = vec![CrossSourceHint {
185 source_uri: "github://issues/42".into(),
186 relation: "mentions".into(),
187 weight: 1.0,
188 }];
189
190 let output = format_hints(&hints);
191 assert!(output.contains("Cross-Source Hints"));
192 assert!(output.contains("github://issues/42"));
193 assert!(output.contains("[mentions]"));
194 }
195}