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!(
113 hints
114 .iter()
115 .any(|h| h.source_uri.contains("schemas/sessions"))
116 );
117 }
118
119 #[test]
120 fn finds_hints_from_reverse_edges() {
121 let edges = vec![edge(
122 "github://issues/42",
123 "src/auth.rs",
124 "mentioned_in",
125 0.8,
126 )];
127
128 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
129 assert_eq!(hints.len(), 1);
130 assert!(hints[0].source_uri.contains("issues/42"));
131 }
132
133 #[test]
134 fn finds_hints_with_absolute_path() {
135 let edges = vec![edge("src/auth.rs", "github://issues/42", "mentions", 1.0)];
136 let hints = hints_for_file("/project/src/auth.rs", &edges, "/project");
137 assert_eq!(hints.len(), 1, "absolute path should match relative edge");
138 }
139
140 #[test]
141 fn ignores_code_to_code_edges() {
142 let edges = vec![edge("src/auth.rs", "src/db.rs", "imports", 1.0)];
143
144 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
145 assert!(hints.is_empty());
146 }
147
148 #[test]
149 fn deduplicates_and_limits_to_5() {
150 let edges: Vec<IndexEdge> = (0..10)
151 .map(|i| {
152 edge(
153 "src/auth.rs",
154 &format!("github://issues/{i}"),
155 "mentions",
156 1.0,
157 )
158 })
159 .collect();
160
161 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
162 assert_eq!(hints.len(), 5);
163 }
164
165 #[test]
166 fn sorts_by_weight_descending() {
167 let edges = vec![
168 edge("src/auth.rs", "github://issues/1", "mentions", 0.5),
169 edge("src/auth.rs", "github://issues/2", "mentions", 1.5),
170 edge("src/auth.rs", "github://issues/3", "mentions", 1.0),
171 ];
172
173 let hints = hints_for_file("src/auth.rs", &edges, ROOT);
174 assert_eq!(hints[0].source_uri, "github://issues/2");
175 assert_eq!(hints[1].source_uri, "github://issues/3");
176 assert_eq!(hints[2].source_uri, "github://issues/1");
177 }
178
179 #[test]
180 fn format_hints_empty_returns_empty() {
181 assert!(format_hints(&[]).is_empty());
182 }
183
184 #[test]
185 fn format_hints_produces_readable_output() {
186 let hints = vec![CrossSourceHint {
187 source_uri: "github://issues/42".into(),
188 relation: "mentions".into(),
189 weight: 1.0,
190 }];
191
192 let output = format_hints(&hints);
193 assert!(output.contains("Cross-Source Hints"));
194 assert!(output.contains("github://issues/42"));
195 assert!(output.contains("[mentions]"));
196 }
197}