1use std::collections::{HashMap, HashSet};
5
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8
9use crate::{AnalysisRun, EffectiveCounts, FileRecord};
10
11#[derive(Debug, Serialize)]
12pub struct SummaryDelta {
13 pub baseline_run_id: String,
14 pub current_run_id: String,
15 pub baseline_timestamp: DateTime<Utc>,
16 pub current_timestamp: DateTime<Utc>,
17 pub baseline_files: u64,
18 pub current_files: u64,
19 pub files_analyzed_delta: i64,
20 pub baseline_code: u64,
21 pub current_code: u64,
22 pub code_lines_delta: i64,
23 pub baseline_comments: u64,
24 pub current_comments: u64,
25 pub comment_lines_delta: i64,
26 pub blank_lines_delta: i64,
27 pub total_lines_delta: i64,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub coverage_lines_hit_delta: Option<i64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub coverage_line_pct_delta: Option<f64>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub baseline_coverage_line_pct: Option<f64>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub current_coverage_line_pct: Option<f64>,
40}
41
42#[derive(Debug, Serialize, PartialEq, Eq, Clone, Copy)]
43#[serde(rename_all = "snake_case")]
44pub enum FileChangeStatus {
45 Added,
46 Removed,
47 Modified,
48 Unchanged,
49}
50
51#[derive(Debug, Serialize)]
52pub struct FileDelta {
53 pub relative_path: String,
54 pub language: Option<String>,
55 pub status: FileChangeStatus,
56 pub baseline_code: i64,
57 pub current_code: i64,
58 pub code_delta: i64,
59 pub baseline_comment: i64,
60 pub current_comment: i64,
61 pub comment_delta: i64,
62 pub baseline_blank: i64,
63 pub current_blank: i64,
64 pub blank_delta: i64,
65 pub total_delta: i64,
66}
67
68#[derive(Debug, Serialize)]
69pub struct ScanComparison {
70 pub summary: SummaryDelta,
71 pub file_deltas: Vec<FileDelta>,
72 pub files_added: usize,
73 pub files_removed: usize,
74 pub files_modified: usize,
75 pub files_unchanged: usize,
76}
77
78fn build_modified(record: &FileRecord, base: &EffectiveCounts, lang: Option<String>) -> FileDelta {
79 let curr = &record.effective_counts;
80 let code_delta = curr.code_lines.cast_signed() - base.code_lines.cast_signed();
81 let comment_delta = curr.comment_lines.cast_signed() - base.comment_lines.cast_signed();
82 let blank_delta = curr.blank_lines.cast_signed() - base.blank_lines.cast_signed();
83 let status = if code_delta == 0 && comment_delta == 0 && blank_delta == 0 {
84 FileChangeStatus::Unchanged
85 } else {
86 FileChangeStatus::Modified
87 };
88 FileDelta {
89 relative_path: record.relative_path.clone(),
90 language: lang,
91 status,
92 baseline_code: base.code_lines.cast_signed(),
93 current_code: curr.code_lines.cast_signed(),
94 code_delta,
95 baseline_comment: base.comment_lines.cast_signed(),
96 current_comment: curr.comment_lines.cast_signed(),
97 comment_delta,
98 baseline_blank: base.blank_lines.cast_signed(),
99 current_blank: curr.blank_lines.cast_signed(),
100 blank_delta,
101 total_delta: code_delta + comment_delta + blank_delta,
102 }
103}
104
105fn build_added(record: &FileRecord, lang: Option<String>) -> FileDelta {
106 let curr = &record.effective_counts;
107 let total = (curr.code_lines + curr.comment_lines + curr.blank_lines).cast_signed();
108 FileDelta {
109 relative_path: record.relative_path.clone(),
110 language: lang,
111 status: FileChangeStatus::Added,
112 baseline_code: 0,
113 current_code: curr.code_lines.cast_signed(),
114 code_delta: curr.code_lines.cast_signed(),
115 baseline_comment: 0,
116 current_comment: curr.comment_lines.cast_signed(),
117 comment_delta: curr.comment_lines.cast_signed(),
118 baseline_blank: 0,
119 current_blank: curr.blank_lines.cast_signed(),
120 blank_delta: curr.blank_lines.cast_signed(),
121 total_delta: total,
122 }
123}
124
125fn build_removed(path: &str, base: &EffectiveCounts, lang: Option<String>) -> FileDelta {
126 let total = (base.code_lines + base.comment_lines + base.blank_lines).cast_signed();
127 FileDelta {
128 relative_path: path.to_string(),
129 language: lang,
130 status: FileChangeStatus::Removed,
131 baseline_code: base.code_lines.cast_signed(),
132 current_code: 0,
133 code_delta: -(base.code_lines.cast_signed()),
134 baseline_comment: base.comment_lines.cast_signed(),
135 current_comment: 0,
136 comment_delta: -(base.comment_lines.cast_signed()),
137 baseline_blank: base.blank_lines.cast_signed(),
138 current_blank: 0,
139 blank_delta: -(base.blank_lines.cast_signed()),
140 total_delta: -total,
141 }
142}
143
144#[allow(clippy::cast_precision_loss)]
145fn coverage_line_pct(hit: u64, found: u64) -> Option<f64> {
146 if found == 0 {
147 None
148 } else {
149 let pct = (hit as f64 / found as f64) * 100.0;
150 Some((pct * 10.0).round() / 10.0)
151 }
152}
153
154#[must_use]
155pub fn compute_delta(baseline: &AnalysisRun, current: &AnalysisRun) -> ScanComparison {
156 let baseline_map: HashMap<&str, &EffectiveCounts> = baseline
157 .per_file_records
158 .iter()
159 .map(|f| (f.relative_path.as_str(), &f.effective_counts))
160 .collect();
161
162 let current_paths: HashSet<&str> = current
163 .per_file_records
164 .iter()
165 .map(|f| f.relative_path.as_str())
166 .collect();
167
168 let mut file_deltas: Vec<FileDelta> = Vec::new();
169
170 for record in ¤t.per_file_records {
171 let path = record.relative_path.as_str();
172 let lang = record.language.map(|l| l.display_name().to_string());
173 if let Some(base) = baseline_map.get(path) {
174 file_deltas.push(build_modified(record, base, lang));
175 } else {
176 file_deltas.push(build_added(record, lang));
177 }
178 }
179
180 for record in &baseline.per_file_records {
181 if !current_paths.contains(record.relative_path.as_str()) {
182 let lang = record.language.map(|l| l.display_name().to_string());
183 file_deltas.push(build_removed(
184 &record.relative_path,
185 &record.effective_counts,
186 lang,
187 ));
188 }
189 }
190
191 file_deltas.sort_by(|a, b| {
192 const fn order(s: FileChangeStatus) -> u8 {
193 match s {
194 FileChangeStatus::Modified => 0,
195 FileChangeStatus::Added => 1,
196 FileChangeStatus::Removed => 2,
197 FileChangeStatus::Unchanged => 3,
198 }
199 }
200 order(a.status)
201 .cmp(&order(b.status))
202 .then(a.relative_path.cmp(&b.relative_path))
203 });
204
205 let files_added = file_deltas
206 .iter()
207 .filter(|f| f.status == FileChangeStatus::Added)
208 .count();
209 let files_removed = file_deltas
210 .iter()
211 .filter(|f| f.status == FileChangeStatus::Removed)
212 .count();
213 let files_modified = file_deltas
214 .iter()
215 .filter(|f| f.status == FileChangeStatus::Modified)
216 .count();
217 let files_unchanged = file_deltas
218 .iter()
219 .filter(|f| f.status == FileChangeStatus::Unchanged)
220 .count();
221
222 let s = ¤t.summary_totals;
223 let b = &baseline.summary_totals;
224
225 let baseline_cov_pct = coverage_line_pct(b.coverage_lines_hit, b.coverage_lines_found);
226 let current_cov_pct = coverage_line_pct(s.coverage_lines_hit, s.coverage_lines_found);
227 let coverage_lines_hit_delta = if b.coverage_lines_found > 0 || s.coverage_lines_found > 0 {
228 Some(s.coverage_lines_hit.cast_signed() - b.coverage_lines_hit.cast_signed())
229 } else {
230 None
231 };
232 let coverage_line_pct_delta = match (baseline_cov_pct, current_cov_pct) {
233 (Some(base_pct), Some(cur_pct)) => Some(((cur_pct - base_pct) * 10.0).round() / 10.0),
234 (None, Some(cur_pct)) => Some(cur_pct),
235 _ => None,
236 };
237
238 ScanComparison {
239 summary: SummaryDelta {
240 baseline_run_id: baseline.tool.run_id.clone(),
241 current_run_id: current.tool.run_id.clone(),
242 baseline_timestamp: baseline.tool.timestamp_utc,
243 current_timestamp: current.tool.timestamp_utc,
244 baseline_files: b.files_analyzed,
245 current_files: s.files_analyzed,
246 files_analyzed_delta: s.files_analyzed.cast_signed() - b.files_analyzed.cast_signed(),
247 baseline_code: b.code_lines,
248 current_code: s.code_lines,
249 code_lines_delta: s.code_lines.cast_signed() - b.code_lines.cast_signed(),
250 baseline_comments: b.comment_lines,
251 current_comments: s.comment_lines,
252 comment_lines_delta: s.comment_lines.cast_signed() - b.comment_lines.cast_signed(),
253 blank_lines_delta: s.blank_lines.cast_signed() - b.blank_lines.cast_signed(),
254 total_lines_delta: s
255 .total_physical_lines
256 .cast_signed()
257 .wrapping_sub(b.total_physical_lines.cast_signed()),
258 coverage_lines_hit_delta,
259 coverage_line_pct_delta,
260 baseline_coverage_line_pct: baseline_cov_pct,
261 current_coverage_line_pct: current_cov_pct,
262 },
263 file_deltas,
264 files_added,
265 files_removed,
266 files_modified,
267 files_unchanged,
268 }
269}