1use crate::ImpactAnalysis;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ConfidenceLevel {
10 High,
12 Medium,
14 Low,
16}
17
18impl std::fmt::Display for ConfidenceLevel {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 ConfidenceLevel::High => write!(f, "High"),
22 ConfidenceLevel::Medium => write!(f, "Medium"),
23 ConfidenceLevel::Low => write!(f, "Low"),
24 }
25 }
26}
27
28#[derive(Debug, Clone)]
30pub struct ConfidenceExplanation {
31 pub level: ConfidenceLevel,
32 pub reasons: Vec<String>,
33 pub suggestions: Vec<String>,
34}
35
36impl ConfidenceExplanation {
37 pub fn from_analysis(analysis: &ImpactAnalysis) -> Self {
39 let mut reasons = Vec::new();
40 let mut suggestions = Vec::new();
41
42 let upstream_count = analysis.upstream.len();
43 let downstream_count = analysis.downstream.len();
44 let total = analysis.total_affected;
45
46 let level = if upstream_count == 0 && downstream_count == 0 {
48 reasons.push("Node appears isolated (no detected connections)".to_string());
50 suggestions
51 .push("Verify if this is called dynamically or from external code".to_string());
52 ConfidenceLevel::Low
53 } else if upstream_count == 0 {
54 reasons.push("Node is an entry point (no internal callers)".to_string());
56 reasons.push(format!("Has {} downstream dependencies", downstream_count));
57 if downstream_count > 5 {
58 suggestions.push("Consider impact on downstream dependencies".to_string());
59 ConfidenceLevel::Medium
60 } else {
61 ConfidenceLevel::High
62 }
63 } else if downstream_count == 0 {
64 reasons.push("Node is a utility (no outgoing dependencies)".to_string());
66 reasons.push(format!("Called by {} upstream nodes", upstream_count));
67 ConfidenceLevel::High
68 } else {
69 reasons.push(format!(
71 "{} callers, {} dependencies",
72 upstream_count, downstream_count
73 ));
74
75 if total > 50 {
76 reasons.push("Very large blast radius".to_string());
77 suggestions
78 .push("This change affects a significant portion of the codebase".to_string());
79 ConfidenceLevel::Low
80 } else if total > 20 {
81 reasons.push("Large blast radius detected".to_string());
82 suggestions
83 .push("Consider breaking this change into smaller refactors".to_string());
84 ConfidenceLevel::Medium
85 } else {
86 reasons.push("Well-connected with manageable impact".to_string());
87 ConfidenceLevel::High
88 }
89 };
90
91 if total > 0 {
93 let direct_count = analysis
94 .upstream
95 .iter()
96 .filter(|n| n.hop_distance == 1)
97 .count();
98 if direct_count > 0 {
99 reasons.push(format!("{} nodes will break immediately", direct_count));
100 }
101 }
102
103 suggestions.push("Tests still recommended for behavioral verification".to_string());
105
106 Self {
107 level,
108 reasons,
109 suggestions,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum NodeRole {
117 EntryPoint,
119 Utility,
121 CoreLogic,
123 Isolated,
125 Adapter,
127}
128
129impl std::fmt::Display for NodeRole {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 NodeRole::EntryPoint => write!(f, "Entry Point"),
133 NodeRole::Utility => write!(f, "Utility"),
134 NodeRole::CoreLogic => write!(f, "Core Logic"),
135 NodeRole::Isolated => write!(f, "Isolated"),
136 NodeRole::Adapter => write!(f, "Adapter"),
137 }
138 }
139}
140
141impl NodeRole {
142 pub fn from_analysis(analysis: &ImpactAnalysis) -> Self {
144 let has_upstream = !analysis.upstream.is_empty();
145 let has_downstream = !analysis.downstream.is_empty();
146
147 match (has_upstream, has_downstream) {
148 (false, false) => NodeRole::Isolated,
149 (false, true) => NodeRole::EntryPoint,
150 (true, false) => NodeRole::Utility,
151 (true, true) => {
152 let upstream_count = analysis.upstream.len();
154 let downstream_count = analysis.downstream.len();
155
156 if (upstream_count <= 2 && downstream_count > 5)
158 || (downstream_count <= 2 && upstream_count > 5)
159 {
160 NodeRole::Adapter
161 } else {
162 NodeRole::CoreLogic
163 }
164 }
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::{AffectedNode, EdgeKind, ImpactDirection, ImpactSeverity, NodeInfo};
173
174 fn node_info(id: &str) -> NodeInfo {
175 NodeInfo {
176 id: id.to_string(),
177 name: id.to_string(),
178 qualified_name: id.to_string(),
179 kind: "function".to_string(),
180 file: "test.rs".to_string(),
181 line_start: 1,
182 line_end: 1,
183 signature: None,
184 centrality: 0.0,
185 }
186 }
187
188 fn affected(id: &str, hop_distance: usize, direction: ImpactDirection) -> AffectedNode {
189 AffectedNode {
190 node_id: crate::NodeId::new(hop_distance),
191 node_info: node_info(id),
192 severity: ImpactSeverity::from_hops(hop_distance),
193 hop_distance,
194 entry_edge: EdgeKind::Calls,
195 direction,
196 }
197 }
198
199 fn analysis(upstream: usize, downstream: usize, total_affected: usize) -> ImpactAnalysis {
200 let upstream_nodes = (0..upstream)
201 .map(|i| {
202 let hop = if i % 2 == 0 { 1 } else { 2 };
203 affected(&format!("u{i}"), hop, ImpactDirection::Upstream)
204 })
205 .collect();
206
207 let downstream_nodes = (0..downstream)
208 .map(|i| {
209 let hop = if i % 2 == 0 { 1 } else { 2 };
210 affected(&format!("d{i}"), hop, ImpactDirection::Downstream)
211 })
212 .collect();
213
214 ImpactAnalysis {
215 target: node_info("target"),
216 upstream: upstream_nodes,
217 downstream: downstream_nodes,
218 total_affected,
219 max_depth: 3,
220 query_time_ms: 1,
221 }
222 }
223
224 #[test]
225 fn test_confidence_level_display() {
226 assert_eq!(ConfidenceLevel::High.to_string(), "High");
227 assert_eq!(ConfidenceLevel::Medium.to_string(), "Medium");
228 assert_eq!(ConfidenceLevel::Low.to_string(), "Low");
229 }
230
231 #[test]
232 fn test_node_role_display() {
233 assert_eq!(NodeRole::EntryPoint.to_string(), "Entry Point");
234 assert_eq!(NodeRole::Utility.to_string(), "Utility");
235 assert_eq!(NodeRole::CoreLogic.to_string(), "Core Logic");
236 assert_eq!(NodeRole::Isolated.to_string(), "Isolated");
237 assert_eq!(NodeRole::Adapter.to_string(), "Adapter");
238 }
239
240 #[test]
241 fn test_confidence_connected_thresholds_regression() {
242 let medium_case = analysis(10, 20, 30);
243 let low_case = analysis(20, 40, 60);
244
245 let medium = ConfidenceExplanation::from_analysis(&medium_case);
246 let low = ConfidenceExplanation::from_analysis(&low_case);
247
248 assert_eq!(medium.level, ConfidenceLevel::Medium);
249 assert_eq!(low.level, ConfidenceLevel::Low);
250 assert!(medium
251 .reasons
252 .iter()
253 .any(|r| r.contains("Large blast radius")));
254 assert!(low
255 .reasons
256 .iter()
257 .any(|r| r.contains("Very large blast radius")));
258 }
259
260 #[test]
261 fn test_confidence_entry_point_matrix_120_cases() {
262 let mut cases = 0;
263 for downstream in 1..=120 {
264 let a = analysis(0, downstream, downstream);
265 let explanation = ConfidenceExplanation::from_analysis(&a);
266 let expected = if downstream > 5 {
267 ConfidenceLevel::Medium
268 } else {
269 ConfidenceLevel::High
270 };
271 assert_eq!(
272 explanation.level, expected,
273 "entry-point mismatch for downstream={downstream}"
274 );
275 cases += 1;
276 }
277 assert_eq!(cases, 120);
278 }
279
280 #[test]
281 fn test_confidence_utility_matrix_120_cases() {
282 let mut cases = 0;
283 for upstream in 1..=120 {
284 let a = analysis(upstream, 0, upstream);
285 let explanation = ConfidenceExplanation::from_analysis(&a);
286 assert_eq!(
287 explanation.level,
288 ConfidenceLevel::High,
289 "utility mismatch for upstream={upstream}"
290 );
291 cases += 1;
292 }
293 assert_eq!(cases, 120);
294 }
295
296 #[test]
297 fn test_confidence_connected_matrix_121_cases() {
298 let mut cases = 0;
299 for upstream in 1..=11 {
300 for downstream in 1..=11 {
301 let total = match (upstream + downstream) % 3 {
304 0 => 15,
305 1 => 35,
306 _ => 70,
307 };
308
309 let expected = if total > 50 {
310 ConfidenceLevel::Low
311 } else if total > 20 {
312 ConfidenceLevel::Medium
313 } else {
314 ConfidenceLevel::High
315 };
316
317 let a = analysis(upstream, downstream, total);
318 let explanation = ConfidenceExplanation::from_analysis(&a);
319 assert_eq!(
320 explanation.level, expected,
321 "connected mismatch for upstream={upstream}, downstream={downstream}, total={total}"
322 );
323 cases += 1;
324 }
325 }
326 assert_eq!(cases, 121);
327 }
328
329 #[test]
330 fn test_node_role_matrix_121_cases() {
331 let mut cases = 0;
332 for upstream in 0..=10 {
333 for downstream in 0..=10 {
334 let a = analysis(upstream, downstream, upstream + downstream);
335 let role = NodeRole::from_analysis(&a);
336 let expected = match (upstream > 0, downstream > 0) {
337 (false, false) => NodeRole::Isolated,
338 (false, true) => NodeRole::EntryPoint,
339 (true, false) => NodeRole::Utility,
340 (true, true) => {
341 if (upstream <= 2 && downstream > 5) || (downstream <= 2 && upstream > 5) {
342 NodeRole::Adapter
343 } else {
344 NodeRole::CoreLogic
345 }
346 }
347 };
348
349 assert_eq!(
350 role, expected,
351 "role mismatch for upstream={upstream}, downstream={downstream}"
352 );
353 cases += 1;
354 }
355 }
356 assert_eq!(cases, 121);
357 }
358
359 #[test]
360 fn test_confidence_standard_suggestion_always_present() {
361 for (upstream, downstream, total) in [
362 (0, 0, 0),
363 (0, 8, 8),
364 (12, 0, 12),
365 (4, 4, 15),
366 (4, 20, 30),
367 (20, 20, 70),
368 ] {
369 let a = analysis(upstream, downstream, total);
370 let explanation = ConfidenceExplanation::from_analysis(&a);
371 assert!(
372 explanation
373 .suggestions
374 .iter()
375 .any(|s| s.contains("Tests still recommended")),
376 "missing standard suggestion for upstream={upstream}, downstream={downstream}, total={total}"
377 );
378 }
379 }
380}