Skip to main content

argus_difflens/
risk.rs

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/// Complete risk analysis for a set of diffs.
10///
11/// # Examples
12///
13/// ```
14/// use argus_difflens::parser::parse_unified_diff;
15/// use argus_difflens::risk::compute_risk;
16///
17/// let diff = "diff --git a/f.rs b/f.rs\n\
18///             --- a/f.rs\n\
19///             +++ b/f.rs\n\
20///             @@ -1,2 +1,3 @@\n\
21///              line\n\
22///             +new\n";
23/// let files = parse_unified_diff(diff).unwrap();
24/// let report = compute_risk(&files);
25/// assert!(report.overall.total >= 0.0);
26/// ```
27#[derive(Debug, Clone, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct RiskReport {
30    /// Aggregate risk score across all files.
31    pub overall: RiskScore,
32    /// Per-file risk breakdown.
33    pub per_file: Vec<FileRisk>,
34    /// High-level summary statistics.
35    pub summary: RiskSummary,
36}
37
38/// Risk details for a single file.
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct FileRisk {
42    /// File path.
43    pub path: std::path::PathBuf,
44    /// Computed risk score.
45    pub score: RiskScore,
46    /// Lines added in this file.
47    pub lines_added: u32,
48    /// Lines deleted in this file.
49    pub lines_deleted: u32,
50    /// Number of hunks in this file.
51    pub hunk_count: usize,
52    /// Overall change classification.
53    pub change_type: ChangeType,
54}
55
56/// Summary statistics for a diff.
57#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct RiskSummary {
60    /// Number of files changed.
61    pub total_files: usize,
62    /// Total lines added across all files.
63    pub total_additions: u32,
64    /// Total lines deleted across all files.
65    pub total_deletions: u32,
66    /// Overall risk classification.
67    pub risk_level: RiskLevel,
68}
69
70/// Categorical risk classification based on score ranges.
71///
72/// # Examples
73///
74/// ```
75/// use argus_difflens::risk::RiskLevel;
76///
77/// let level = RiskLevel::from_score(30.0);
78/// assert!(matches!(level, RiskLevel::Medium));
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum RiskLevel {
83    /// Score 0–25.
84    Low,
85    /// Score 26–50.
86    Medium,
87    /// Score 51–75.
88    High,
89    /// Score 76–100.
90    Critical,
91}
92
93impl RiskLevel {
94    /// Map a numeric score to a risk level.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use argus_difflens::risk::RiskLevel;
100    ///
101    /// assert_eq!(RiskLevel::from_score(10.0), RiskLevel::Low);
102    /// assert_eq!(RiskLevel::from_score(50.0), RiskLevel::Medium);
103    /// assert_eq!(RiskLevel::from_score(60.0), RiskLevel::High);
104    /// assert_eq!(RiskLevel::from_score(90.0), RiskLevel::Critical);
105    /// ```
106    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
130/// Compute a risk report from parsed file diffs.
131///
132/// Scoring uses size, file-type heuristics, and keyword-based complexity
133/// deltas. Coverage is set to 0 (requires coverage data).
134///
135/// # Examples
136///
137/// ```
138/// use argus_difflens::risk::compute_risk;
139///
140/// let report = compute_risk(&[]);
141/// assert_eq!(report.summary.total_files, 0);
142/// assert_eq!(report.overall.total, 0.0);
143/// ```
144pub 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
230/// Compute the cyclomatic complexity delta for a single hunk.
231///
232/// Counts branch keywords (`if`, `else if`, `elif`, `match`, `for`,
233/// `while`, `loop`, `?`, `catch`, `except`, `case`) in added vs removed
234/// lines. Returns a score from 0 to 100.
235///
236/// # Examples
237///
238/// ```
239/// use argus_core::{DiffHunk, ChangeType};
240/// use std::path::PathBuf;
241/// use argus_difflens::risk::compute_complexity_delta;
242///
243/// let hunk = DiffHunk {
244///     file_path: PathBuf::from("test.rs"),
245///     old_start: 1, old_lines: 1, new_start: 1, new_lines: 4,
246///     content: "+if x > 0 {\n+    for i in items {\n+    }\n+}\n".into(),
247///     change_type: ChangeType::Modify,
248/// };
249/// let score = compute_complexity_delta(&hunk);
250/// assert!(score > 0.0);
251/// ```
252pub 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    // Ternary operator
304    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    /// Render the report as a markdown string.
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use argus_difflens::risk::compute_risk;
378    ///
379    /// let report = compute_risk(&[]);
380    /// let md = report.to_markdown();
381    /// assert!(md.contains("# Risk Report"));
382    /// ```
383    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        // 3 files * 20 = 60 diffusion
476        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        // 3 branch keywords * 15 = 45
536        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        // 2 removed branches, delta = abs(-2) * 15 = 30
552        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        // added: 2 (if, for), removed: 1 (if), delta = 1 * 15 = 15
583        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}