Skip to main content

reposcry_report/
lib.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use reposcry_context::ContextPack;
5use reposcry_git::{GitChange, GitIntegration};
6use reposcry_graph::edge::EdgeKind;
7use reposcry_graph::graph::CodeGraph;
8use reposcry_rules::{RuleViolation, RulesEngine};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ReviewReport {
12    pub summary: ReportSummary,
13    pub changed_files: Vec<GitChange>,
14    pub high_risk_changes: Vec<RiskItem>,
15    pub new_dependencies: Vec<String>,
16    pub new_cycles: Vec<Vec<String>>,
17    pub violations: Vec<RuleViolation>,
18    pub suggested_reviewers: Vec<String>,
19    pub suggested_tests: Vec<String>,
20    pub context: Option<ContextPack>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ReportSummary {
25    pub changed_files_count: u32,
26    pub impacted_files_count: u32,
27    pub risk: RiskLevel,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub enum RiskLevel {
32    Low,
33    Medium,
34    High,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RiskItem {
39    pub file: String,
40    pub reason: String,
41}
42
43pub fn generate_report(
44    graph: &CodeGraph,
45    git: &GitIntegration,
46    rules_engine: &RulesEngine,
47    base: &str,
48    head: &str,
49) -> Result<ReviewReport> {
50    // Get changed files
51    let changes = git.diff_files(base, head)?;
52    let changed_paths: Vec<String> = changes.iter().map(|c| c.path.clone()).collect();
53
54    // Find impacted files (reverse deps of changed files)
55    let mut impacted = Vec::new();
56    for edge in graph
57        .edges
58        .iter()
59        .filter(|edge| edge.kind == EdgeKind::Imports)
60    {
61        let source_path = graph
62            .nodes
63            .get(&edge.source_id)
64            .and_then(|n| n.file_path.as_deref());
65        let target_path = graph
66            .nodes
67            .get(&edge.target_id)
68            .and_then(|n| n.file_path.as_deref());
69        if let Some(tp) = target_path {
70            if changed_paths.contains(&tp.to_string()) {
71                if let Some(sp) = source_path {
72                    if !changed_paths.contains(&sp.to_string()) {
73                        impacted.push(sp.to_string());
74                    }
75                }
76            }
77        }
78    }
79    impacted.sort();
80    impacted.dedup();
81
82    // Compute risk
83    let risk = if impacted.len() > 20 {
84        RiskLevel::High
85    } else if impacted.len() > 5 {
86        RiskLevel::Medium
87    } else {
88        RiskLevel::Low
89    };
90
91    // High risk items
92    let mut high_risk = Vec::new();
93    for path in &changed_paths {
94        let rdeps = graph
95            .edges
96            .iter()
97            .filter(|e| e.kind == EdgeKind::Imports)
98            .filter(|e| {
99                graph
100                    .nodes
101                    .get(&e.target_id)
102                    .and_then(|n| n.file_path.as_deref())
103                    == Some(path.as_str())
104            })
105            .count();
106        if rdeps > 5 {
107            high_risk.push(RiskItem {
108                file: path.clone(),
109                reason: format!("high fan-in ({} dependents), changed", rdeps),
110            });
111        }
112    }
113
114    // Detect cycles
115    let cycles = graph.detect_cycles();
116    let new_cycles: Vec<Vec<String>> = cycles
117        .into_iter()
118        .map(|cycle| {
119            cycle
120                .iter()
121                .filter_map(|id| graph.get_node(*id))
122                .map(|n| n.name.clone())
123                .collect()
124        })
125        .collect();
126
127    // Check rules
128    let violations = rules_engine.check_graph(graph);
129
130    // Suggested reviewers
131    let mut suggested_reviewers = Vec::new();
132    for path in &changed_paths {
133        if let Ok(owner) = git.file_owner(path) {
134            if !suggested_reviewers.contains(&owner) {
135                suggested_reviewers.push(owner);
136            }
137        }
138    }
139
140    Ok(ReviewReport {
141        summary: ReportSummary {
142            changed_files_count: changes.len() as u32,
143            impacted_files_count: impacted.len() as u32,
144            risk,
145        },
146        changed_files: changes,
147        high_risk_changes: high_risk,
148        new_dependencies: Vec::new(),
149        new_cycles,
150        violations,
151        suggested_reviewers,
152        suggested_tests: Vec::new(),
153        context: None,
154    })
155}
156
157pub fn render_markdown(report: &ReviewReport) -> String {
158    let mut md = String::new();
159    md.push_str("# RepoScry Report\n\n");
160    md.push_str(&format!(
161        "## Summary\n\nChanged files: {}\nImpacted files: {}\nRisk: {:?}\n\n",
162        report.summary.changed_files_count,
163        report.summary.impacted_files_count,
164        report.summary.risk
165    ));
166    if !report.high_risk_changes.is_empty() {
167        md.push_str("## High-risk changes\n\n| File | Reason |\n|---|---|\n");
168        for item in &report.high_risk_changes {
169            md.push_str(&format!("| {} | {} |\n", item.file, item.reason));
170        }
171        md.push('\n');
172    }
173    if !report.new_cycles.is_empty() {
174        md.push_str("## New dependency cycles\n\n");
175        for cycle in &report.new_cycles {
176            md.push_str(&format!("- {}\n", cycle.join(" → ")));
177        }
178        md.push('\n');
179    }
180    if !report.violations.is_empty() {
181        md.push_str("## Architecture violations\n\n");
182        for v in &report.violations {
183            md.push_str(&format!(
184                "- [{}] {}: {}\n",
185                v.severity.as_str(),
186                v.rule,
187                v.message
188            ));
189        }
190        md.push('\n');
191    }
192    if !report.suggested_reviewers.is_empty() {
193        md.push_str("## Suggested reviewers\n\n");
194        for reviewer in &report.suggested_reviewers {
195            md.push_str(&format!("- {}\n", reviewer));
196        }
197        md.push('\n');
198    }
199    md
200}