1use crate::analysis::flow::brandes_betweenness;
2use crate::model::{
3 Confidence, Edge, EdgeKind, FileRiskScore, RiskFactors, RiskScore, RiskStats, RiskWeights,
4 SymbolNode,
5};
6use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8
9pub fn compute_criticality_scores(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
12 let nodes: HashSet<String> = symbols.iter().map(|s| s.qualified_name.clone()).collect();
13 brandes_betweenness(&nodes, edges)
14}
15
16pub fn compute_coupling_scores(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
21 let symbol_set: HashSet<&str> = symbols.iter().map(|s| s.qualified_name.as_str()).collect();
22
23 let relevant_edges: Vec<&Edge> = edges
25 .iter()
26 .filter(|e| e.kind.confidence() != Confidence::Structural)
27 .filter(|e| {
28 symbol_set.contains(e.source.as_str()) && symbol_set.contains(e.target.as_str())
29 })
30 .collect();
31
32 let mut degrees: HashMap<&str, usize> = HashMap::new();
34 for name in &symbol_set {
35 degrees.insert(name, 0);
36 }
37 for edge in &relevant_edges {
38 *degrees.entry(edge.source.as_str()).or_default() += 1; *degrees.entry(edge.target.as_str()).or_default() += 1; }
41
42 let max_degree = degrees.values().copied().max().unwrap_or(0);
43 if max_degree == 0 {
44 return symbols
45 .iter()
46 .map(|s| (s.qualified_name.clone(), 0.0))
47 .collect();
48 }
49
50 symbols
51 .iter()
52 .map(|s| {
53 let deg = degrees.get(s.qualified_name.as_str()).copied().unwrap_or(0);
54 (s.qualified_name.clone(), deg as f64 / max_degree as f64)
55 })
56 .collect()
57}
58
59pub fn compute_test_gaps(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
61 let tested: HashSet<&str> = edges
63 .iter()
64 .filter(|e| e.kind == EdgeKind::TestedBy)
65 .map(|e| e.target.as_str())
66 .collect();
67
68 symbols
69 .iter()
70 .map(|s| {
71 let gap = if tested.contains(s.qualified_name.as_str()) {
72 0.0
73 } else {
74 1.0
75 };
76 (s.qualified_name.clone(), gap)
77 })
78 .collect()
79}
80
81pub fn split_into_segments(s: &str) -> Vec<String> {
84 let mut segments = Vec::new();
85 for part in s.split([':', '.', '_', '/']) {
87 if part.is_empty() {
88 continue;
89 }
90 let mut current = String::new();
92 let chars: Vec<char> = part.chars().collect();
93 for i in 0..chars.len() {
94 if i > 0
95 && chars[i].is_uppercase()
96 && chars[i - 1].is_lowercase()
97 && !current.is_empty()
98 {
99 segments.push(current.to_lowercase());
100 current.clear();
101 }
102 current.push(chars[i]);
103 }
104 if !current.is_empty() {
105 segments.push(current.to_lowercase());
106 }
107 }
108 segments
109}
110
111pub fn compute_sensitivity(symbols: &[SymbolNode], patterns: &[String]) -> HashMap<String, f64> {
114 let lower_patterns: Vec<String> = patterns.iter().map(|p| p.to_lowercase()).collect();
115
116 symbols
117 .iter()
118 .map(|s| {
119 let mut all_segments = split_into_segments(&s.qualified_name);
120 for decorator in &s.decorators {
121 all_segments.extend(split_into_segments(decorator));
122 }
123
124 let matched = all_segments.iter().any(|segment| {
125 lower_patterns
126 .iter()
127 .any(|pattern| segment.starts_with(pattern))
128 });
129
130 (s.qualified_name.clone(), if matched { 1.0 } else { 0.0 })
131 })
132 .collect()
133}
134
135pub fn score_symbols(
137 symbols: &[SymbolNode],
138 criticality: &HashMap<String, f64>,
139 coupling: &HashMap<String, f64>,
140 test_gaps: &HashMap<String, f64>,
141 sensitivity: &HashMap<String, f64>,
142 weights: &RiskWeights,
143) -> Vec<RiskScore> {
144 let w = weights.normalized();
145 let mut scores: Vec<RiskScore> = symbols
146 .iter()
147 .map(|s| {
148 let name = &s.qualified_name;
149 let crit = criticality.get(name).copied().unwrap_or(0.0);
150 let coup = coupling.get(name).copied().unwrap_or(0.0);
151 let tgap = test_gaps.get(name).copied().unwrap_or(1.0);
152 let sens = sensitivity.get(name).copied().unwrap_or(0.0);
153 let composite = (w.criticality * crit
154 + w.coupling * coup
155 + w.test_gap * tgap
156 + w.sensitivity * sens)
157 .clamp(0.0, 1.0);
158 RiskScore {
159 qualified_name: name.clone(),
160 composite,
161 factors: RiskFactors {
162 criticality: crit,
163 coupling: coup,
164 test_gap: tgap,
165 sensitivity: sens,
166 },
167 }
168 })
169 .collect();
170 scores.sort_by(|a, b| {
171 b.composite
172 .partial_cmp(&a.composite)
173 .unwrap_or(std::cmp::Ordering::Equal)
174 });
175 scores
176}
177
178pub fn aggregate_file_scores(
182 symbol_scores: &[RiskScore],
183 symbols: &[SymbolNode],
184) -> Vec<FileRiskScore> {
185 let name_to_file: HashMap<&str, &PathBuf> = symbols
187 .iter()
188 .map(|s| (s.qualified_name.as_str(), &s.location.file))
189 .collect();
190
191 let mut file_map: HashMap<PathBuf, (f64, usize, String)> = HashMap::new();
193 for score in symbol_scores {
194 if let Some(&file) = name_to_file.get(score.qualified_name.as_str()) {
195 let entry = file_map
196 .entry(file.clone())
197 .or_insert((0.0, 0, String::new()));
198 entry.1 += 1; if score.composite > entry.0 {
200 entry.0 = score.composite;
201 entry.2 = score.qualified_name.clone();
202 }
203 }
204 }
205
206 let mut file_scores: Vec<FileRiskScore> = file_map
207 .into_iter()
208 .map(
209 |(path, (composite, symbol_count, highest_symbol))| FileRiskScore {
210 path,
211 composite,
212 symbol_count,
213 highest_symbol,
214 },
215 )
216 .collect();
217 file_scores.sort_by(|a, b| {
218 b.composite
219 .partial_cmp(&a.composite)
220 .unwrap_or(std::cmp::Ordering::Equal)
221 });
222 file_scores
223}
224
225pub fn compute_risk_stats(scores: &[RiskScore], file_count: usize) -> RiskStats {
227 if scores.is_empty() {
228 return RiskStats {
229 symbols_scored: 0,
230 files_scored: file_count,
231 avg_risk: 0.0,
232 median_risk: 0.0,
233 p90_risk: 0.0,
234 };
235 }
236 let mut composites: Vec<f64> = scores.iter().map(|s| s.composite).collect();
237 composites.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
238 let n = composites.len();
239 let avg = composites.iter().sum::<f64>() / n as f64;
240 let median = if n.is_multiple_of(2) {
241 (composites[n / 2 - 1] + composites[n / 2]) / 2.0
242 } else {
243 composites[n / 2]
244 };
245 let p90_idx = ((n as f64) * 0.9).ceil() as usize;
246 let p90 = composites[p90_idx.min(n - 1)];
247 RiskStats {
248 symbols_scored: n,
249 files_scored: file_count,
250 avg_risk: avg,
251 median_risk: median,
252 p90_risk: p90,
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::model::{Edge, EdgeKind, Location, RiskWeights, SymbolKind, SymbolNode, Visibility};
260
261 fn make_symbol(name: &str, file: &str) -> SymbolNode {
262 SymbolNode {
263 name: name.split("::").last().unwrap_or(name).into(),
264 qualified_name: name.into(),
265 kind: SymbolKind::Function,
266 location: Location {
267 file: file.into(),
268 line_start: 1,
269 line_end: 10,
270 col_start: 0,
271 col_end: 0,
272 },
273 visibility: Visibility::Public,
274 is_exported: true,
275 is_async: false,
276 is_test: false,
277 decorators: vec![],
278 signature: None,
279 }
280 }
281
282 fn make_edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
283 Edge {
284 kind,
285 source: source.into(),
286 target: target.into(),
287 metadata: None,
288 }
289 }
290
291 #[test]
292 fn test_criticality_delegates_to_brandes() {
293 let symbols = vec![
295 make_symbol("a::A", "a.rs"),
296 make_symbol("b::B", "b.rs"),
297 make_symbol("c::C", "c.rs"),
298 ];
299 let edges = vec![
300 make_edge("a::A", "b::B", EdgeKind::Calls),
301 make_edge("b::B", "c::C", EdgeKind::Calls),
302 ];
303 let scores = compute_criticality_scores(&symbols, &edges);
304 assert!(scores.get("b::B").unwrap_or(&0.0) >= scores.get("a::A").unwrap_or(&0.0));
305 assert!(scores.get("b::B").unwrap_or(&0.0) >= scores.get("c::C").unwrap_or(&0.0));
306 }
307
308 #[test]
309 fn test_coupling_excludes_structural_edges() {
310 let symbols = vec![make_symbol("a::A", "a.rs"), make_symbol("b::B", "b.rs")];
311 let edges = vec![
312 make_edge("a::A", "b::B", EdgeKind::Calls), make_edge("a::A", "b::B", EdgeKind::Contains), ];
315 let scores = compute_coupling_scores(&symbols, &edges);
316 assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
319 assert!((scores["b::B"] - 1.0).abs() < f64::EPSILON);
320 }
321
322 #[test]
323 fn test_coupling_both_endpoints_must_be_symbols() {
324 let symbols = vec![make_symbol("a::A", "a.rs")];
325 let edges = vec![
326 make_edge("a::A", "file.rs", EdgeKind::Calls), ];
328 let scores = compute_coupling_scores(&symbols, &edges);
329 assert!((scores["a::A"]).abs() < f64::EPSILON);
331 }
332
333 #[test]
334 fn test_coupling_max_degree_zero() {
335 let symbols = vec![make_symbol("a::A", "a.rs"), make_symbol("b::B", "b.rs")];
336 let edges: Vec<Edge> = vec![];
337 let scores = compute_coupling_scores(&symbols, &edges);
338 assert!((scores["a::A"]).abs() < f64::EPSILON);
339 assert!((scores["b::B"]).abs() < f64::EPSILON);
340 }
341
342 #[test]
343 fn test_coupling_normalization() {
344 let symbols = vec![
346 make_symbol("a::A", "a.rs"),
347 make_symbol("b::B", "b.rs"),
348 make_symbol("c::C", "c.rs"),
349 ];
350 let edges = vec![
351 make_edge("a::A", "b::B", EdgeKind::Calls),
352 make_edge("a::A", "c::C", EdgeKind::Calls),
353 make_edge("b::B", "c::C", EdgeKind::Calls),
354 ];
355 let scores = compute_coupling_scores(&symbols, &edges);
356 assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
361 assert!((scores["b::B"] - 1.0).abs() < f64::EPSILON);
362 assert!((scores["c::C"] - 1.0).abs() < f64::EPSILON);
363 }
364
365 #[test]
366 fn test_test_gap_untested() {
367 let symbols = vec![make_symbol("a::A", "a.rs")];
368 let edges: Vec<Edge> = vec![]; let scores = compute_test_gaps(&symbols, &edges);
370 assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
371 }
372
373 #[test]
374 fn test_test_gap_tested() {
375 let symbols = vec![make_symbol("a::A", "a.rs")];
376 let edges = vec![make_edge("test::test_a", "a::A", EdgeKind::TestedBy)];
377 let scores = compute_test_gaps(&symbols, &edges);
378 assert!((scores["a::A"]).abs() < f64::EPSILON);
379 }
380
381 #[test]
382 fn test_sensitivity_word_boundary() {
383 let symbols = vec![
384 make_symbol("src/auth.rs::auth_service", "src/auth.rs"),
385 make_symbol("src/lib.rs::HashMap", "src/lib.rs"),
386 ];
387 let patterns = vec!["auth".into(), "hash".into()];
388 let scores = compute_sensitivity(&symbols, &patterns);
389 assert!((scores["src/auth.rs::auth_service"] - 1.0).abs() < f64::EPSILON);
391 assert!((scores["src/lib.rs::HashMap"] - 1.0).abs() < f64::EPSILON);
403 }
404
405 #[test]
406 fn test_sensitivity_camel_case() {
407 let symbols = vec![make_symbol("mod::AuthToken", "mod.rs")];
408 let patterns = vec!["auth".into()];
409 let scores = compute_sensitivity(&symbols, &patterns);
410 assert!((scores["mod::AuthToken"] - 1.0).abs() < f64::EPSILON);
412 }
413
414 #[test]
415 fn test_sensitivity_decorators() {
416 let mut sym = make_symbol("mod::handler", "mod.rs");
417 sym.decorators = vec!["auth_required".into()];
418 let patterns = vec!["auth".into()];
419 let scores = compute_sensitivity(&[sym], &patterns);
420 assert!((scores["mod::handler"] - 1.0).abs() < f64::EPSILON);
421 }
422
423 #[test]
424 fn test_sensitivity_no_match() {
425 let symbols = vec![make_symbol("mod::foo_bar", "mod.rs")];
426 let patterns = vec!["auth".into(), "sql".into()];
427 let scores = compute_sensitivity(&symbols, &patterns);
428 assert!((scores["mod::foo_bar"]).abs() < f64::EPSILON);
429 }
430
431 #[test]
432 fn test_split_segments() {
433 let segments = split_into_segments("src/lib.rs::AuthService");
434 assert!(segments.contains(&"auth".to_string()));
436 assert!(segments.contains(&"service".to_string()));
437 assert!(segments.contains(&"src".to_string()));
438 assert!(segments.contains(&"lib".to_string()));
439 }
440
441 #[test]
442 fn test_score_symbols_weighted_sum() {
443 let symbols = vec![make_symbol("a::A", "a.rs")];
444 let mut crit = HashMap::new();
445 crit.insert("a::A".to_string(), 0.8);
446 let mut coup = HashMap::new();
447 coup.insert("a::A".to_string(), 0.6);
448 let mut tgap = HashMap::new();
449 tgap.insert("a::A".to_string(), 1.0);
450 let mut sens = HashMap::new();
451 sens.insert("a::A".to_string(), 0.5);
452 let weights = RiskWeights::default(); let scores = score_symbols(&symbols, &crit, &coup, &tgap, &sens, &weights);
454 assert!((scores[0].composite - 0.74).abs() < 0.001);
456 }
457
458 #[test]
459 fn test_aggregate_file_scores() {
460 let symbols = vec![make_symbol("a::X", "a.rs"), make_symbol("a::Y", "a.rs")];
461 let scores = vec![
462 RiskScore {
463 qualified_name: "a::X".into(),
464 composite: 0.8,
465 factors: RiskFactors {
466 criticality: 0.0,
467 coupling: 0.0,
468 test_gap: 0.0,
469 sensitivity: 0.0,
470 },
471 },
472 RiskScore {
473 qualified_name: "a::Y".into(),
474 composite: 0.5,
475 factors: RiskFactors {
476 criticality: 0.0,
477 coupling: 0.0,
478 test_gap: 0.0,
479 sensitivity: 0.0,
480 },
481 },
482 ];
483 let file_scores = aggregate_file_scores(&scores, &symbols);
484 assert_eq!(file_scores.len(), 1);
485 assert!((file_scores[0].composite - 0.8).abs() < f64::EPSILON);
486 assert_eq!(file_scores[0].highest_symbol, "a::X");
487 assert_eq!(file_scores[0].symbol_count, 2);
488 }
489
490 #[test]
491 fn test_risk_stats_median() {
492 let scores = vec![
493 RiskScore {
494 qualified_name: "a".into(),
495 composite: 0.1,
496 factors: RiskFactors {
497 criticality: 0.0,
498 coupling: 0.0,
499 test_gap: 0.0,
500 sensitivity: 0.0,
501 },
502 },
503 RiskScore {
504 qualified_name: "b".into(),
505 composite: 0.5,
506 factors: RiskFactors {
507 criticality: 0.0,
508 coupling: 0.0,
509 test_gap: 0.0,
510 sensitivity: 0.0,
511 },
512 },
513 RiskScore {
514 qualified_name: "c".into(),
515 composite: 0.9,
516 factors: RiskFactors {
517 criticality: 0.0,
518 coupling: 0.0,
519 test_gap: 0.0,
520 sensitivity: 0.0,
521 },
522 },
523 ];
524 let stats = compute_risk_stats(&scores, 2);
525 assert!((stats.median_risk - 0.5).abs() < f64::EPSILON);
526 assert!((stats.avg_risk - 0.5).abs() < f64::EPSILON);
527 }
528}