ricecoder_execution/
risk_scorer.rs

1//! Risk scoring for execution plans
2//!
3//! Calculates risk scores for execution plans based on:
4//! - Number of files changed
5//! - Critical files (Cargo.toml, package.json, etc.)
6//! - File deletions
7//! - Overall scope (number of steps)
8
9use crate::models::{ExecutionPlan, ExecutionStep, RiskFactor, RiskLevel, RiskScore, StepAction};
10use std::collections::HashSet;
11
12/// Risk scorer for execution plans
13#[derive(Debug, Clone)]
14pub struct ExecutionRiskScorer {
15    /// Critical file patterns that require higher risk weighting
16    critical_files: HashSet<String>,
17    /// Approval threshold (0.0 to 1.0+)
18    approval_threshold: f32,
19}
20
21impl ExecutionRiskScorer {
22    /// Create a new risk scorer with default critical files
23    pub fn new() -> Self {
24        let mut critical_files = HashSet::new();
25        // Rust
26        critical_files.insert("Cargo.toml".to_string());
27        critical_files.insert("Cargo.lock".to_string());
28        // Node.js
29        critical_files.insert("package.json".to_string());
30        critical_files.insert("package-lock.json".to_string());
31        critical_files.insert("yarn.lock".to_string());
32        // Python
33        critical_files.insert("setup.py".to_string());
34        critical_files.insert("requirements.txt".to_string());
35        critical_files.insert("pyproject.toml".to_string());
36        // Go
37        critical_files.insert("go.mod".to_string());
38        critical_files.insert("go.sum".to_string());
39        // Configuration
40        critical_files.insert(".env".to_string());
41        critical_files.insert(".env.production".to_string());
42        critical_files.insert("config.yaml".to_string());
43        critical_files.insert("config.yml".to_string());
44        // Build/CI
45        critical_files.insert("Makefile".to_string());
46        critical_files.insert(".github/workflows".to_string());
47        critical_files.insert(".gitlab-ci.yml".to_string());
48
49        Self {
50            critical_files,
51            approval_threshold: 1.5, // High risk threshold
52        }
53    }
54
55    /// Create a new risk scorer with custom critical files
56    pub fn with_critical_files(critical_files: HashSet<String>) -> Self {
57        Self {
58            critical_files,
59            approval_threshold: 1.5,
60        }
61    }
62
63    /// Set the approval threshold
64    pub fn with_approval_threshold(mut self, threshold: f32) -> Self {
65        self.approval_threshold = threshold;
66        self
67    }
68
69    /// Add a critical file pattern
70    pub fn add_critical_file(&mut self, pattern: String) {
71        self.critical_files.insert(pattern);
72    }
73
74    /// Check if a file path matches a critical file pattern
75    fn is_critical_file(&self, path: &str) -> bool {
76        // Check exact matches
77        if self.critical_files.contains(path) {
78            return true;
79        }
80
81        // Check filename only
82        if let Some(filename) = path.split('/').next_back() {
83            if self.critical_files.contains(filename) {
84                return true;
85            }
86        }
87
88        // Check directory patterns
89        for pattern in &self.critical_files {
90            if path.contains(pattern) {
91                return true;
92            }
93        }
94
95        false
96    }
97
98    /// Calculate risk score for an execution plan
99    ///
100    /// **Feature: ricecoder-execution, Property 1: Risk Score Consistency**
101    /// **Validates: Requirements 1.1, 1.2**
102    pub fn score_plan(&self, plan: &ExecutionPlan) -> RiskScore {
103        let mut factors = Vec::new();
104        let mut total_score = 0.0;
105
106        // Factor 1: Number of files changed
107        let file_count = plan
108            .steps
109            .iter()
110            .filter(|s| matches!(s.action, StepAction::ModifyFile { .. }))
111            .count();
112        let file_count_weight = file_count as f32 * 0.1;
113        factors.push(RiskFactor {
114            name: "file_count".to_string(),
115            weight: file_count_weight,
116            description: format!("{} files modified", file_count),
117        });
118        total_score += file_count_weight;
119
120        // Factor 2: Critical files
121        let critical_files_count = plan
122            .steps
123            .iter()
124            .filter(|s| self.is_critical_file_in_step(s))
125            .count();
126        let critical_files_weight = critical_files_count as f32 * 0.5;
127        factors.push(RiskFactor {
128            name: "critical_files".to_string(),
129            weight: critical_files_weight,
130            description: format!("{} critical files", critical_files_count),
131        });
132        total_score += critical_files_weight;
133
134        // Factor 3: Deletions
135        let deletions = plan
136            .steps
137            .iter()
138            .filter(|s| matches!(s.action, StepAction::DeleteFile { .. }))
139            .count();
140        let deletions_weight = deletions as f32 * 0.3;
141        factors.push(RiskFactor {
142            name: "deletions".to_string(),
143            weight: deletions_weight,
144            description: format!("{} files deleted", deletions),
145        });
146        total_score += deletions_weight;
147
148        // Factor 4: Scope (number of steps)
149        let scope_weight = (plan.steps.len() as f32 / 10.0).min(0.2);
150        factors.push(RiskFactor {
151            name: "scope".to_string(),
152            weight: scope_weight,
153            description: format!("{} steps", plan.steps.len()),
154        });
155        total_score += scope_weight;
156
157        let level = self.level_from_score(total_score);
158
159        RiskScore {
160            level,
161            score: total_score,
162            factors,
163        }
164    }
165
166    /// Determine risk level from score
167    fn level_from_score(&self, score: f32) -> RiskLevel {
168        match score {
169            s if s < 0.5 => RiskLevel::Low,
170            s if s < 1.5 => RiskLevel::Medium,
171            s if s < 2.5 => RiskLevel::High,
172            _ => RiskLevel::Critical,
173        }
174    }
175
176    /// Check if approval is required based on risk score
177    pub fn requires_approval(&self, risk_score: &RiskScore) -> bool {
178        risk_score.score > self.approval_threshold
179    }
180
181    /// Check if a step contains a critical file
182    fn is_critical_file_in_step(&self, step: &ExecutionStep) -> bool {
183        match &step.action {
184            StepAction::CreateFile { path, .. } => self.is_critical_file(path),
185            StepAction::ModifyFile { path, .. } => self.is_critical_file(path),
186            StepAction::DeleteFile { path } => self.is_critical_file(path),
187            _ => false,
188        }
189    }
190}
191
192impl Default for ExecutionRiskScorer {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::models::{ComplexityLevel, ExecutionStep, StepStatus};
202    use uuid::Uuid;
203
204    fn create_test_plan(steps: Vec<ExecutionStep>) -> ExecutionPlan {
205        ExecutionPlan {
206            id: Uuid::new_v4().to_string(),
207            name: "Test Plan".to_string(),
208            steps,
209            risk_score: RiskScore::default(),
210            estimated_duration: std::time::Duration::from_secs(0),
211            estimated_complexity: ComplexityLevel::Simple,
212            requires_approval: false,
213            editable: true,
214        }
215    }
216
217    fn create_test_step(description: &str, action: StepAction) -> ExecutionStep {
218        ExecutionStep {
219            id: Uuid::new_v4().to_string(),
220            description: description.to_string(),
221            action,
222            risk_score: RiskScore::default(),
223            dependencies: Vec::new(),
224            rollback_action: None,
225            status: StepStatus::Pending,
226        }
227    }
228
229    #[test]
230    fn test_empty_plan_low_risk() {
231        let scorer = ExecutionRiskScorer::new();
232        let plan = create_test_plan(vec![]);
233        let score = scorer.score_plan(&plan);
234        assert_eq!(score.level, RiskLevel::Low);
235        assert_eq!(score.score, 0.0);
236    }
237
238    #[test]
239    fn test_single_file_modification_low_risk() {
240        let scorer = ExecutionRiskScorer::new();
241        let step = create_test_step(
242            "Modify file",
243            StepAction::ModifyFile {
244                path: "src/main.rs".to_string(),
245                diff: "".to_string(),
246            },
247        );
248        let plan = create_test_plan(vec![step]);
249        let score = scorer.score_plan(&plan);
250        assert_eq!(score.level, RiskLevel::Low);
251    }
252
253    #[test]
254    fn test_critical_file_modification_high_risk() {
255        let scorer = ExecutionRiskScorer::new();
256        let step = create_test_step(
257            "Modify Cargo.toml",
258            StepAction::ModifyFile {
259                path: "Cargo.toml".to_string(),
260                diff: "".to_string(),
261            },
262        );
263        let plan = create_test_plan(vec![step]);
264        let score = scorer.score_plan(&plan);
265        assert!(
266            score.score > 0.4,
267            "Critical file should increase risk score"
268        );
269    }
270
271    #[test]
272    fn test_file_deletion_increases_risk() {
273        let scorer = ExecutionRiskScorer::new();
274        let step = create_test_step(
275            "Delete file",
276            StepAction::DeleteFile {
277                path: "src/old.rs".to_string(),
278            },
279        );
280        let plan = create_test_plan(vec![step]);
281        let score = scorer.score_plan(&plan);
282        assert!(
283            score.score > 0.2,
284            "File deletion should increase risk score"
285        );
286    }
287
288    #[test]
289    fn test_multiple_files_increase_risk() {
290        let scorer = ExecutionRiskScorer::new();
291        let steps = vec![
292            create_test_step(
293                "Modify file 1",
294                StepAction::ModifyFile {
295                    path: "src/a.rs".to_string(),
296                    diff: "".to_string(),
297                },
298            ),
299            create_test_step(
300                "Modify file 2",
301                StepAction::ModifyFile {
302                    path: "src/b.rs".to_string(),
303                    diff: "".to_string(),
304                },
305            ),
306            create_test_step(
307                "Modify file 3",
308                StepAction::ModifyFile {
309                    path: "src/c.rs".to_string(),
310                    diff: "".to_string(),
311                },
312            ),
313        ];
314        let plan = create_test_plan(steps);
315        let score = scorer.score_plan(&plan);
316        assert!(
317            score.score > 0.2,
318            "Multiple files should increase risk score"
319        );
320    }
321
322    #[test]
323    fn test_approval_threshold() {
324        let scorer = ExecutionRiskScorer::new().with_approval_threshold(0.4);
325        let step = create_test_step(
326            "Modify Cargo.toml",
327            StepAction::ModifyFile {
328                path: "Cargo.toml".to_string(),
329                diff: "".to_string(),
330            },
331        );
332        let plan = create_test_plan(vec![step]);
333        let score = scorer.score_plan(&plan);
334        assert!(
335            scorer.requires_approval(&score),
336            "Score {} should require approval with threshold 0.4",
337            score.score
338        );
339    }
340
341    #[test]
342    fn test_custom_critical_files() {
343        let mut critical_files = HashSet::new();
344        critical_files.insert("custom.conf".to_string());
345        let scorer = ExecutionRiskScorer::with_critical_files(critical_files);
346
347        let step = create_test_step(
348            "Modify custom config",
349            StepAction::ModifyFile {
350                path: "custom.conf".to_string(),
351                diff: "".to_string(),
352            },
353        );
354        let plan = create_test_plan(vec![step]);
355        let score = scorer.score_plan(&plan);
356        assert!(
357            score.score > 0.4,
358            "Custom critical file should increase risk"
359        );
360    }
361
362    #[test]
363    fn test_risk_score_consistency() {
364        // **Feature: ricecoder-execution, Property 1: Risk Score Consistency**
365        // **Validates: Requirements 1.1, 1.2**
366        let scorer = ExecutionRiskScorer::new();
367        let step = create_test_step(
368            "Modify Cargo.toml",
369            StepAction::ModifyFile {
370                path: "Cargo.toml".to_string(),
371                diff: "".to_string(),
372            },
373        );
374        let plan = create_test_plan(vec![step]);
375
376        // Score the same plan multiple times
377        let score1 = scorer.score_plan(&plan);
378        let score2 = scorer.score_plan(&plan);
379        let score3 = scorer.score_plan(&plan);
380
381        // All scores should be identical
382        assert_eq!(score1.score, score2.score);
383        assert_eq!(score2.score, score3.score);
384        assert_eq!(score1.level, score2.level);
385        assert_eq!(score2.level, score3.level);
386    }
387}