1use std::fmt;
2use std::path::Path;
3
4use argus_core::{ChangeType, DiffHunk, RiskScore};
5use serde::{Deserialize, Serialize};
6
7use crate::parser::FileDiff;
8
9#[derive(Debug, Clone, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct RiskReport {
30 pub overall: RiskScore,
32 pub per_file: Vec<FileRisk>,
34 pub summary: RiskSummary,
36}
37
38#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct FileRisk {
42 pub path: std::path::PathBuf,
44 pub score: RiskScore,
46 pub lines_added: u32,
48 pub lines_deleted: u32,
50 pub hunk_count: usize,
52 pub change_type: ChangeType,
54}
55
56#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct RiskSummary {
60 pub total_files: usize,
62 pub total_additions: u32,
64 pub total_deletions: u32,
66 pub risk_level: RiskLevel,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum RiskLevel {
83 Low,
85 Medium,
87 High,
89 Critical,
91}
92
93impl RiskLevel {
94 pub fn from_score(score: f64) -> Self {
107 if score <= 25.0 {
108 RiskLevel::Low
109 } else if score <= 50.0 {
110 RiskLevel::Medium
111 } else if score <= 75.0 {
112 RiskLevel::High
113 } else {
114 RiskLevel::Critical
115 }
116 }
117}
118
119impl fmt::Display for RiskLevel {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 RiskLevel::Low => write!(f, "Low"),
123 RiskLevel::Medium => write!(f, "Medium"),
124 RiskLevel::High => write!(f, "High"),
125 RiskLevel::Critical => write!(f, "Critical"),
126 }
127 }
128}
129
130pub fn compute_risk(diffs: &[FileDiff]) -> RiskReport {
145 if diffs.is_empty() {
146 return RiskReport {
147 overall: RiskScore::new(0.0, 0.0, 0.0, 0.0, 0.0),
148 per_file: Vec::new(),
149 summary: RiskSummary {
150 total_files: 0,
151 total_additions: 0,
152 total_deletions: 0,
153 risk_level: RiskLevel::Low,
154 },
155 };
156 }
157
158 let mut per_file = Vec::with_capacity(diffs.len());
159 let mut total_additions: u32 = 0;
160 let mut total_deletions: u32 = 0;
161 let mut max_file_type_score: f64 = 0.0;
162
163 for diff in diffs {
164 let (added, deleted) = count_lines(diff);
165 total_additions += added;
166 total_deletions += deleted;
167
168 let lines_changed = (added + deleted) as f64;
169 let size = (lines_changed * 2.0).min(100.0);
170 let diffusion = (diff.hunks.len() as f64 * 20.0).min(100.0);
171 let file_type_score = file_type_risk(&diff.new_path);
172 if file_type_score > max_file_type_score {
173 max_file_type_score = file_type_score;
174 }
175
176 let file_complexity = compute_file_complexity_delta(diff);
177 let change_type = dominant_change_type(diff);
178
179 per_file.push(FileRisk {
180 path: diff.new_path.clone(),
181 score: RiskScore::new(size, file_complexity, diffusion, 0.0, file_type_score),
182 lines_added: added,
183 lines_deleted: deleted,
184 hunk_count: diff.hunks.len(),
185 change_type,
186 });
187 }
188
189 let total_lines = (total_additions + total_deletions) as f64;
190 let overall_size = (total_lines * 2.0).min(100.0);
191 let overall_diffusion = (diffs.len() as f64 * 20.0).min(100.0);
192 let overall_complexity = compute_avg_complexity_delta(diffs);
193 let overall = RiskScore::new(
194 overall_size,
195 overall_complexity,
196 overall_diffusion,
197 0.0,
198 max_file_type_score,
199 );
200
201 let summary = RiskSummary {
202 total_files: diffs.len(),
203 total_additions,
204 total_deletions,
205 risk_level: RiskLevel::from_score(overall.total),
206 };
207
208 RiskReport {
209 overall,
210 per_file,
211 summary,
212 }
213}
214
215fn count_lines(diff: &FileDiff) -> (u32, u32) {
216 let mut added: u32 = 0;
217 let mut deleted: u32 = 0;
218 for hunk in &diff.hunks {
219 for line in hunk.content.lines() {
220 if line.starts_with('+') {
221 added += 1;
222 } else if line.starts_with('-') {
223 deleted += 1;
224 }
225 }
226 }
227 (added, deleted)
228}
229
230pub fn compute_complexity_delta(hunk: &DiffHunk) -> f64 {
253 let mut added_branches: i64 = 0;
254 let mut removed_branches: i64 = 0;
255
256 for line in hunk.content.lines() {
257 if let Some(code) = line.strip_prefix('+') {
258 added_branches += count_branch_keywords(code);
259 } else if let Some(code) = line.strip_prefix('-') {
260 removed_branches += count_branch_keywords(code);
261 }
262 }
263
264 let delta = added_branches - removed_branches;
265 (delta.unsigned_abs() as f64 * 15.0).min(100.0)
266}
267
268fn compute_file_complexity_delta(diff: &FileDiff) -> f64 {
269 if diff.hunks.is_empty() {
270 return 0.0;
271 }
272 let mut total = 0.0;
273 for hunk in &diff.hunks {
274 total += compute_complexity_delta(hunk);
275 }
276 (total / diff.hunks.len() as f64).min(100.0)
277}
278
279fn compute_avg_complexity_delta(diffs: &[FileDiff]) -> f64 {
280 if diffs.is_empty() {
281 return 0.0;
282 }
283 let mut total = 0.0;
284 for diff in diffs {
285 total += compute_file_complexity_delta(diff);
286 }
287 (total / diffs.len() as f64).min(100.0)
288}
289
290const BRANCH_KEYWORDS: &[&str] = &[
291 "if ", "else if ", "elif ", "match ", "for ", "while ", "loop ", "loop{", "catch ", "catch(",
292 "except ", "except:", "case ",
293];
294
295fn count_branch_keywords(line: &str) -> i64 {
296 let trimmed = line.trim();
297 let mut count: i64 = 0;
298 for kw in BRANCH_KEYWORDS {
299 if trimmed.starts_with(kw) || trimmed.contains(&format!(" {kw}")) {
300 count += 1;
301 }
302 }
303 if trimmed.contains('?') && trimmed.contains(':') {
305 count += 1;
306 }
307 count
308}
309
310fn dominant_change_type(diff: &FileDiff) -> ChangeType {
311 if diff.is_new_file {
312 return ChangeType::Add;
313 }
314 if diff.is_deleted_file {
315 return ChangeType::Delete;
316 }
317 if diff.is_rename {
318 return ChangeType::Move;
319 }
320 ChangeType::Modify
321}
322
323fn file_type_risk(path: &Path) -> f64 {
324 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
325 match ext {
326 "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "c" | "cpp" | "h" => 50.0,
327 "toml" | "yaml" | "yml" | "json" => 20.0,
328 "md" | "txt" | "rst" => 5.0,
329 _ => 30.0,
330 }
331}
332
333impl fmt::Display for RiskReport {
334 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335 writeln!(f, "Risk Report")?;
336 writeln!(f, "===========")?;
337 writeln!(
338 f,
339 "Overall Risk: {:.1}/100 ({})\n",
340 self.overall.total, self.summary.risk_level
341 )?;
342
343 if !self.per_file.is_empty() {
344 writeln!(
345 f,
346 "{:<40} {:>8} {:>10} {:>8}",
347 "File", "Change", "+/-", "Risk"
348 )?;
349 writeln!(f, "{}", "-".repeat(70))?;
350 for fr in &self.per_file {
351 writeln!(
352 f,
353 "{:<40} {:>8} {:>+4}/{:<-4} {:>5.1}",
354 fr.path.display(),
355 fr.change_type,
356 fr.lines_added,
357 fr.lines_deleted,
358 fr.score.total,
359 )?;
360 }
361 }
362
363 writeln!(
364 f,
365 "\nSummary: {} files, +{} additions, -{} deletions",
366 self.summary.total_files, self.summary.total_additions, self.summary.total_deletions
367 )
368 }
369}
370
371impl RiskReport {
372 pub fn to_markdown(&self) -> String {
384 let mut out = String::new();
385 out.push_str("# Risk Report\n\n");
386 out.push_str(&format!(
387 "**Overall Risk:** {:.1}/100 ({})\n\n",
388 self.overall.total, self.summary.risk_level
389 ));
390
391 if !self.per_file.is_empty() {
392 out.push_str("| File | Change | +/- | Risk |\n");
393 out.push_str("|------|--------|-----|------|\n");
394 for fr in &self.per_file {
395 out.push_str(&format!(
396 "| {} | {} | +{}/-{} | {:.1} |\n",
397 fr.path.display(),
398 fr.change_type,
399 fr.lines_added,
400 fr.lines_deleted,
401 fr.score.total,
402 ));
403 }
404 out.push('\n');
405 }
406
407 out.push_str(&format!(
408 "**Summary:** {} files, +{} additions, -{} deletions\n",
409 self.summary.total_files, self.summary.total_additions, self.summary.total_deletions
410 ));
411 out
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::parser::parse_unified_diff;
419
420 #[test]
421 fn empty_diff_risk() {
422 let report = compute_risk(&[]);
423 assert_eq!(report.summary.total_files, 0);
424 assert_eq!(report.overall.total, 0.0);
425 assert_eq!(report.summary.risk_level, RiskLevel::Low);
426 }
427
428 #[test]
429 fn single_file_risk() {
430 let diff = "\
431diff --git a/src/main.rs b/src/main.rs
432--- a/src/main.rs
433+++ b/src/main.rs
434@@ -1,3 +1,6 @@
435 fn main() {
436+ let a = 1;
437+ let b = 2;
438+ let c = 3;
439 }
440";
441 let files = parse_unified_diff(diff).unwrap();
442 let report = compute_risk(&files);
443 assert_eq!(report.summary.total_files, 1);
444 assert_eq!(report.summary.total_additions, 3);
445 assert_eq!(report.summary.total_deletions, 0);
446 assert!(report.overall.total > 0.0);
447 assert_eq!(report.per_file[0].change_type, ChangeType::Modify);
448 }
449
450 #[test]
451 fn multi_file_increases_diffusion() {
452 let diff = "\
453diff --git a/a.rs b/a.rs
454--- a/a.rs
455+++ b/a.rs
456@@ -1 +1,2 @@
457 a
458+b
459diff --git a/b.rs b/b.rs
460--- a/b.rs
461+++ b/b.rs
462@@ -1 +1,2 @@
463 a
464+b
465diff --git a/c.rs b/c.rs
466--- a/c.rs
467+++ b/c.rs
468@@ -1 +1,2 @@
469 a
470+b
471";
472 let files = parse_unified_diff(diff).unwrap();
473 let report = compute_risk(&files);
474 assert_eq!(report.summary.total_files, 3);
475 assert!((report.overall.diffusion - 60.0).abs() < f64::EPSILON);
477 }
478
479 #[test]
480 fn file_type_scoring() {
481 assert_eq!(file_type_risk(Path::new("main.rs")), 50.0);
482 assert_eq!(file_type_risk(Path::new("config.toml")), 20.0);
483 assert_eq!(file_type_risk(Path::new("README.md")), 5.0);
484 assert_eq!(file_type_risk(Path::new("data.csv")), 30.0);
485 assert_eq!(file_type_risk(Path::new("app.py")), 50.0);
486 assert_eq!(file_type_risk(Path::new("index.ts")), 50.0);
487 }
488
489 #[test]
490 fn risk_level_boundaries() {
491 assert_eq!(RiskLevel::from_score(0.0), RiskLevel::Low);
492 assert_eq!(RiskLevel::from_score(25.0), RiskLevel::Low);
493 assert_eq!(RiskLevel::from_score(25.1), RiskLevel::Medium);
494 assert_eq!(RiskLevel::from_score(50.0), RiskLevel::Medium);
495 assert_eq!(RiskLevel::from_score(50.1), RiskLevel::High);
496 assert_eq!(RiskLevel::from_score(75.0), RiskLevel::High);
497 assert_eq!(RiskLevel::from_score(75.1), RiskLevel::Critical);
498 assert_eq!(RiskLevel::from_score(100.0), RiskLevel::Critical);
499 }
500
501 #[test]
502 fn display_and_markdown_output() {
503 let diff = "\
504diff --git a/f.rs b/f.rs
505--- a/f.rs
506+++ b/f.rs
507@@ -1 +1,2 @@
508 x
509+y
510";
511 let files = parse_unified_diff(diff).unwrap();
512 let report = compute_risk(&files);
513 let text = format!("{report}");
514 assert!(text.contains("Risk Report"));
515 assert!(text.contains("f.rs"));
516
517 let md = report.to_markdown();
518 assert!(md.contains("# Risk Report"));
519 assert!(md.contains("f.rs"));
520 }
521
522 #[test]
523 fn complexity_delta_added_branches() {
524 let hunk = DiffHunk {
525 file_path: std::path::PathBuf::from("test.rs"),
526 old_start: 1,
527 old_lines: 1,
528 new_start: 1,
529 new_lines: 5,
530 content: "+if x > 0 {\n+ for i in items {\n+ while running {\n+ }\n+ }\n+}\n"
531 .into(),
532 change_type: ChangeType::Modify,
533 };
534 let score = compute_complexity_delta(&hunk);
535 assert!((score - 45.0).abs() < f64::EPSILON);
537 }
538
539 #[test]
540 fn complexity_delta_removed_branches() {
541 let hunk = DiffHunk {
542 file_path: std::path::PathBuf::from("test.rs"),
543 old_start: 1,
544 old_lines: 3,
545 new_start: 1,
546 new_lines: 0,
547 content: "-if x > 0 {\n- match val {\n- }\n-}\n".into(),
548 change_type: ChangeType::Modify,
549 };
550 let score = compute_complexity_delta(&hunk);
551 assert!((score - 30.0).abs() < f64::EPSILON);
553 }
554
555 #[test]
556 fn complexity_delta_no_branch_changes() {
557 let hunk = DiffHunk {
558 file_path: std::path::PathBuf::from("test.rs"),
559 old_start: 1,
560 old_lines: 1,
561 new_start: 1,
562 new_lines: 2,
563 content: "+let x = 42;\n+let y = x + 1;\n".into(),
564 change_type: ChangeType::Modify,
565 };
566 let score = compute_complexity_delta(&hunk);
567 assert!((score - 0.0).abs() < f64::EPSILON);
568 }
569
570 #[test]
571 fn complexity_delta_mixed_add_remove() {
572 let hunk = DiffHunk {
573 file_path: std::path::PathBuf::from("test.rs"),
574 old_start: 1,
575 old_lines: 2,
576 new_start: 1,
577 new_lines: 3,
578 content: "-if old_check {\n+if new_check {\n+ for item in list {\n+ }\n".into(),
579 change_type: ChangeType::Modify,
580 };
581 let score = compute_complexity_delta(&hunk);
582 assert!((score - 15.0).abs() < f64::EPSILON);
584 }
585
586 #[test]
587 fn risk_score_uses_real_complexity() {
588 let diff = "\
589diff --git a/complex.rs b/complex.rs
590--- a/complex.rs
591+++ b/complex.rs
592@@ -1,1 +1,5 @@
593 fn main() {
594+ if x > 0 {
595+ for i in items {
596+ while running {
597+ }
598+ }
599+ }
600 }
601";
602 let files = parse_unified_diff(diff).unwrap();
603 let report = compute_risk(&files);
604 assert!(
605 report.overall.complexity > 0.0,
606 "complexity should be non-zero for diffs with branch changes"
607 );
608 }
609}