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