1use crate::models::{ExecutionPlan, ExecutionStep, RiskFactor, RiskLevel, RiskScore, StepAction};
10use std::collections::HashSet;
11
12#[derive(Debug, Clone)]
14pub struct ExecutionRiskScorer {
15 critical_files: HashSet<String>,
17 approval_threshold: f32,
19}
20
21impl ExecutionRiskScorer {
22 pub fn new() -> Self {
24 let mut critical_files = HashSet::new();
25 critical_files.insert("Cargo.toml".to_string());
27 critical_files.insert("Cargo.lock".to_string());
28 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 critical_files.insert("setup.py".to_string());
34 critical_files.insert("requirements.txt".to_string());
35 critical_files.insert("pyproject.toml".to_string());
36 critical_files.insert("go.mod".to_string());
38 critical_files.insert("go.sum".to_string());
39 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 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, }
53 }
54
55 pub fn with_critical_files(critical_files: HashSet<String>) -> Self {
57 Self {
58 critical_files,
59 approval_threshold: 1.5,
60 }
61 }
62
63 pub fn with_approval_threshold(mut self, threshold: f32) -> Self {
65 self.approval_threshold = threshold;
66 self
67 }
68
69 pub fn add_critical_file(&mut self, pattern: String) {
71 self.critical_files.insert(pattern);
72 }
73
74 fn is_critical_file(&self, path: &str) -> bool {
76 if self.critical_files.contains(path) {
78 return true;
79 }
80
81 if let Some(filename) = path.split('/').next_back() {
83 if self.critical_files.contains(filename) {
84 return true;
85 }
86 }
87
88 for pattern in &self.critical_files {
90 if path.contains(pattern) {
91 return true;
92 }
93 }
94
95 false
96 }
97
98 pub fn score_plan(&self, plan: &ExecutionPlan) -> RiskScore {
103 let mut factors = Vec::new();
104 let mut total_score = 0.0;
105
106 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 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 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 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 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 pub fn requires_approval(&self, risk_score: &RiskScore) -> bool {
178 risk_score.score > self.approval_threshold
179 }
180
181 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 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 let score1 = scorer.score_plan(&plan);
378 let score2 = scorer.score_plan(&plan);
379 let score3 = scorer.score_plan(&plan);
380
381 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}