Skip to main content

the_code_graph_domain/use_cases/
risk.rs

1use crate::analysis::risk::{
2    aggregate_file_scores, compute_coupling_scores, compute_criticality_scores, compute_risk_stats,
3    compute_sensitivity, compute_test_gaps, score_symbols,
4};
5use crate::error::{CodeGraphError, Result};
6use crate::model::*;
7use crate::ports::GraphStore;
8
9pub struct RiskUseCase<S> {
10    store: S,
11}
12
13impl<S: GraphStore> RiskUseCase<S> {
14    pub fn new(store: S) -> Self {
15        Self { store }
16    }
17
18    /// Full risk analysis: compute all factors, score symbols, aggregate files.
19    pub fn analyze(&self, config: &RiskConfig) -> Result<RiskAnalysis> {
20        let symbols = self.store.all_symbols()?;
21        let edges = self.store.all_edges()?;
22
23        let criticality = compute_criticality_scores(&symbols, &edges);
24        let coupling = compute_coupling_scores(&symbols, &edges);
25        let test_gaps = compute_test_gaps(&symbols, &edges);
26        let sensitivity = compute_sensitivity(&symbols, &config.security_patterns);
27
28        let symbol_scores = score_symbols(
29            &symbols,
30            &criticality,
31            &coupling,
32            &test_gaps,
33            &sensitivity,
34            &config.weights,
35        );
36        let file_scores = aggregate_file_scores(&symbol_scores, &symbols);
37        let stats = compute_risk_stats(&symbol_scores, file_scores.len());
38
39        Ok(RiskAnalysis {
40            symbol_scores,
41            file_scores,
42            stats,
43        })
44    }
45
46    /// Score a single symbol (loads full graph, filters to one result).
47    pub fn score_symbol(&self, qualified_name: &str, config: &RiskConfig) -> Result<RiskScore> {
48        let analysis = self.analyze(config)?;
49        analysis
50            .symbol_scores
51            .into_iter()
52            .find(|s| s.qualified_name == qualified_name)
53            .ok_or_else(|| {
54                CodeGraphError::Resolution(format!("symbol not found: {qualified_name}"))
55            })
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::test_support::InMemoryGraphStore;
63
64    fn build_store() -> InMemoryGraphStore {
65        let mut store = InMemoryGraphStore::new();
66        // Symbol A: main function, calls B, untested, name has "auth"
67        store.insert_symbol(SymbolNode {
68            name: "auth_handler".into(),
69            qualified_name: "src/auth.rs::auth_handler".into(),
70            kind: SymbolKind::Function,
71            location: Location {
72                file: "src/auth.rs".into(),
73                line_start: 1,
74                line_end: 20,
75                col_start: 0,
76                col_end: 0,
77            },
78            visibility: Visibility::Public,
79            is_exported: true,
80            is_async: false,
81            is_test: false,
82            decorators: vec![],
83            signature: None,
84        });
85        // Symbol B: utility function, tested
86        store.insert_symbol(SymbolNode {
87            name: "parse_input".into(),
88            qualified_name: "src/util.rs::parse_input".into(),
89            kind: SymbolKind::Function,
90            location: Location {
91                file: "src/util.rs".into(),
92                line_start: 1,
93                line_end: 10,
94                col_start: 0,
95                col_end: 0,
96            },
97            visibility: Visibility::Public,
98            is_exported: true,
99            is_async: false,
100            is_test: false,
101            decorators: vec![],
102            signature: None,
103        });
104        // Edge: auth_handler calls parse_input
105        store.insert_edge(Edge {
106            kind: EdgeKind::Calls,
107            source: "src/auth.rs::auth_handler".into(),
108            target: "src/util.rs::parse_input".into(),
109            metadata: None,
110        });
111        // Edge: parse_input is tested
112        store.insert_edge(Edge {
113            kind: EdgeKind::TestedBy,
114            source: "test::test_parse".into(),
115            target: "src/util.rs::parse_input".into(),
116            metadata: None,
117        });
118        store
119    }
120
121    #[test]
122    fn test_use_case_analyze() {
123        let store = build_store();
124        let uc = RiskUseCase::new(store);
125        let analysis = uc.analyze(&RiskConfig::default()).unwrap();
126        assert_eq!(analysis.symbol_scores.len(), 2);
127        assert_eq!(analysis.file_scores.len(), 2);
128        // auth_handler should have higher risk (untested + security sensitive)
129        let auth_score = analysis
130            .symbol_scores
131            .iter()
132            .find(|s| s.qualified_name == "src/auth.rs::auth_handler")
133            .unwrap();
134        let util_score = analysis
135            .symbol_scores
136            .iter()
137            .find(|s| s.qualified_name == "src/util.rs::parse_input")
138            .unwrap();
139        assert!(auth_score.composite > util_score.composite);
140        // auth_handler should have sensitivity = 1.0
141        assert!((auth_score.factors.sensitivity - 1.0).abs() < f64::EPSILON);
142        // parse_input should have test_gap = 0.0 (it's tested)
143        assert!((util_score.factors.test_gap).abs() < f64::EPSILON);
144    }
145
146    #[test]
147    fn test_use_case_score_symbol() {
148        let store = build_store();
149        let uc = RiskUseCase::new(store);
150        let score = uc
151            .score_symbol("src/auth.rs::auth_handler", &RiskConfig::default())
152            .unwrap();
153        assert_eq!(score.qualified_name, "src/auth.rs::auth_handler");
154        assert!(score.composite > 0.0);
155    }
156
157    #[test]
158    fn test_use_case_score_symbol_not_found() {
159        let store = build_store();
160        let uc = RiskUseCase::new(store);
161        let result = uc.score_symbol("nonexistent::symbol", &RiskConfig::default());
162        assert!(result.is_err());
163    }
164
165    #[test]
166    fn test_weight_normalization() {
167        let store = build_store();
168        let uc = RiskUseCase::new(store);
169        // All weights = 1.0 should produce same relative ranking as defaults
170        let config = RiskConfig {
171            weights: RiskWeights {
172                criticality: 1.0,
173                coupling: 1.0,
174                test_gap: 1.0,
175                sensitivity: 1.0,
176            },
177            ..RiskConfig::default()
178        };
179        let analysis = uc.analyze(&config).unwrap();
180        // Should still work — normalized internally to 0.25 each
181        assert!(!analysis.symbol_scores.is_empty());
182        for score in &analysis.symbol_scores {
183            assert!(score.composite >= 0.0 && score.composite <= 1.0);
184        }
185    }
186}