1use std::collections::{BTreeSet, 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 pub files_total: usize,
78}
79
80fn build_modified(record: &FileRecord, base: &EffectiveCounts, lang: Option<String>) -> FileDelta {
81 let curr = &record.effective_counts;
82 let code_delta = curr.code_lines.cast_signed() - base.code_lines.cast_signed();
83 let comment_delta = curr.comment_lines.cast_signed() - base.comment_lines.cast_signed();
84 let blank_delta = curr.blank_lines.cast_signed() - base.blank_lines.cast_signed();
85 let status = if code_delta == 0 && comment_delta == 0 && blank_delta == 0 {
86 FileChangeStatus::Unchanged
87 } else {
88 FileChangeStatus::Modified
89 };
90 FileDelta {
91 relative_path: record.relative_path.clone(),
92 language: lang,
93 status,
94 baseline_code: base.code_lines.cast_signed(),
95 current_code: curr.code_lines.cast_signed(),
96 code_delta,
97 baseline_comment: base.comment_lines.cast_signed(),
98 current_comment: curr.comment_lines.cast_signed(),
99 comment_delta,
100 baseline_blank: base.blank_lines.cast_signed(),
101 current_blank: curr.blank_lines.cast_signed(),
102 blank_delta,
103 total_delta: code_delta + comment_delta + blank_delta,
104 }
105}
106
107fn build_added(record: &FileRecord, lang: Option<String>) -> FileDelta {
108 let curr = &record.effective_counts;
109 let total = (curr.code_lines + curr.comment_lines + curr.blank_lines).cast_signed();
110 FileDelta {
111 relative_path: record.relative_path.clone(),
112 language: lang,
113 status: FileChangeStatus::Added,
114 baseline_code: 0,
115 current_code: curr.code_lines.cast_signed(),
116 code_delta: curr.code_lines.cast_signed(),
117 baseline_comment: 0,
118 current_comment: curr.comment_lines.cast_signed(),
119 comment_delta: curr.comment_lines.cast_signed(),
120 baseline_blank: 0,
121 current_blank: curr.blank_lines.cast_signed(),
122 blank_delta: curr.blank_lines.cast_signed(),
123 total_delta: total,
124 }
125}
126
127fn build_removed(path: &str, base: &EffectiveCounts, lang: Option<String>) -> FileDelta {
128 let total = (base.code_lines + base.comment_lines + base.blank_lines).cast_signed();
129 FileDelta {
130 relative_path: path.to_string(),
131 language: lang,
132 status: FileChangeStatus::Removed,
133 baseline_code: base.code_lines.cast_signed(),
134 current_code: 0,
135 code_delta: -(base.code_lines.cast_signed()),
136 baseline_comment: base.comment_lines.cast_signed(),
137 current_comment: 0,
138 comment_delta: -(base.comment_lines.cast_signed()),
139 baseline_blank: base.blank_lines.cast_signed(),
140 current_blank: 0,
141 blank_delta: -(base.blank_lines.cast_signed()),
142 total_delta: -total,
143 }
144}
145
146#[allow(clippy::cast_precision_loss)]
147fn coverage_line_pct(hit: u64, found: u64) -> Option<f64> {
148 if found == 0 {
149 None
150 } else {
151 let pct = (hit as f64 / found as f64) * 100.0;
152 Some((pct * 10.0).round() / 10.0)
153 }
154}
155
156#[must_use]
157#[allow(clippy::too_many_lines)]
158pub fn compute_delta(baseline: &AnalysisRun, current: &AnalysisRun) -> ScanComparison {
159 let baseline_map: HashMap<&str, &EffectiveCounts> = baseline
160 .per_file_records
161 .iter()
162 .map(|f| (f.relative_path.as_str(), &f.effective_counts))
163 .collect();
164
165 let current_paths: HashSet<&str> = current
166 .per_file_records
167 .iter()
168 .map(|f| f.relative_path.as_str())
169 .collect();
170
171 let mut file_deltas: Vec<FileDelta> = Vec::new();
172
173 for record in ¤t.per_file_records {
174 let path = record.relative_path.as_str();
175 let lang = record.language.map(|l| l.display_name().to_string());
176 if let Some(base) = baseline_map.get(path) {
177 file_deltas.push(build_modified(record, base, lang));
178 } else {
179 file_deltas.push(build_added(record, lang));
180 }
181 }
182
183 for record in &baseline.per_file_records {
184 if !current_paths.contains(record.relative_path.as_str()) {
185 let lang = record.language.map(|l| l.display_name().to_string());
186 file_deltas.push(build_removed(
187 &record.relative_path,
188 &record.effective_counts,
189 lang,
190 ));
191 }
192 }
193
194 file_deltas.sort_by(|a, b| {
195 const fn order(s: FileChangeStatus) -> u8 {
196 match s {
197 FileChangeStatus::Modified => 0,
198 FileChangeStatus::Added => 1,
199 FileChangeStatus::Removed => 2,
200 FileChangeStatus::Unchanged => 3,
201 }
202 }
203 order(a.status)
204 .cmp(&order(b.status))
205 .then(a.relative_path.cmp(&b.relative_path))
206 });
207
208 let files_added = file_deltas
209 .iter()
210 .filter(|f| f.status == FileChangeStatus::Added)
211 .count();
212 let files_removed = file_deltas
213 .iter()
214 .filter(|f| f.status == FileChangeStatus::Removed)
215 .count();
216 let files_modified = file_deltas
217 .iter()
218 .filter(|f| f.status == FileChangeStatus::Modified)
219 .count();
220 let files_unchanged = file_deltas
221 .iter()
222 .filter(|f| f.status == FileChangeStatus::Unchanged)
223 .count();
224
225 let s = ¤t.summary_totals;
226 let b = &baseline.summary_totals;
227
228 let baseline_cov_pct = coverage_line_pct(b.coverage_lines_hit, b.coverage_lines_found);
229 let current_cov_pct = coverage_line_pct(s.coverage_lines_hit, s.coverage_lines_found);
230 let coverage_lines_hit_delta = if b.coverage_lines_found > 0 || s.coverage_lines_found > 0 {
231 Some(s.coverage_lines_hit.cast_signed() - b.coverage_lines_hit.cast_signed())
232 } else {
233 None
234 };
235 let coverage_line_pct_delta = match (baseline_cov_pct, current_cov_pct) {
236 (Some(base_pct), Some(cur_pct)) => Some(((cur_pct - base_pct) * 10.0).round() / 10.0),
237 (None, Some(cur_pct)) => Some(cur_pct),
238 _ => None,
239 };
240
241 ScanComparison {
242 summary: SummaryDelta {
243 baseline_run_id: baseline.tool.run_id.clone(),
244 current_run_id: current.tool.run_id.clone(),
245 baseline_timestamp: baseline.tool.timestamp_utc,
246 current_timestamp: current.tool.timestamp_utc,
247 baseline_files: b.files_analyzed,
248 current_files: s.files_analyzed,
249 files_analyzed_delta: s.files_analyzed.cast_signed() - b.files_analyzed.cast_signed(),
250 baseline_code: b.code_lines,
251 current_code: s.code_lines,
252 code_lines_delta: s.code_lines.cast_signed() - b.code_lines.cast_signed(),
253 baseline_comments: b.comment_lines,
254 current_comments: s.comment_lines,
255 comment_lines_delta: s.comment_lines.cast_signed() - b.comment_lines.cast_signed(),
256 blank_lines_delta: s.blank_lines.cast_signed() - b.blank_lines.cast_signed(),
257 total_lines_delta: s
258 .total_physical_lines
259 .cast_signed()
260 .wrapping_sub(b.total_physical_lines.cast_signed()),
261 coverage_lines_hit_delta,
262 coverage_line_pct_delta,
263 baseline_coverage_line_pct: baseline_cov_pct,
264 current_coverage_line_pct: current_cov_pct,
265 },
266 file_deltas,
267 files_added,
268 files_removed,
269 files_modified,
270 files_unchanged,
271 files_total: files_added + files_removed + files_modified + files_unchanged,
272 }
273}
274
275#[derive(Debug, Serialize)]
279pub struct MultiScanPoint {
280 pub run_id: String,
281 pub timestamp: DateTime<Utc>,
282 pub git_commit: Option<String>,
283 pub git_branch: Option<String>,
284 pub git_tags: Option<String>,
285 pub git_nearest_tag: Option<String>,
286 pub code_lines: i64,
287 pub comment_lines: i64,
288 pub blank_lines: i64,
289 pub files_analyzed: i64,
290 pub test_count: i64,
291 pub coverage_line_pct: Option<f64>,
292}
293
294#[derive(Debug, Serialize)]
296pub struct MultiFileDelta {
297 pub relative_path: String,
298 pub language: Option<String>,
299 pub code_per_scan: Vec<Option<i64>>,
301 pub code_delta_per_scan: Vec<Option<i64>>,
303 pub overall_status: String,
305 pub total_code_delta: i64,
307}
308
309#[derive(Debug, Serialize)]
310pub struct MultiScanComparison {
311 pub points: Vec<MultiScanPoint>,
312 pub sequential_deltas: Vec<ScanComparison>,
314 pub total_delta: SummaryDelta,
316 pub file_matrix: Vec<MultiFileDelta>,
318}
319
320fn sequential_code_deltas(code_per_scan: &[Option<i64>]) -> Vec<Option<i64>> {
323 let mut deltas = vec![None];
324 for i in 1..code_per_scan.len() {
325 if code_per_scan[i - 1].is_some() || code_per_scan[i].is_some() {
326 let prev = code_per_scan[i - 1].unwrap_or(0);
327 let curr = code_per_scan[i].unwrap_or(0);
328 deltas.push(Some(curr - prev));
329 } else {
330 deltas.push(None);
331 }
332 }
333 deltas
334}
335
336fn classify_file_status(code_per_scan: &[Option<i64>]) -> &'static str {
338 let n = code_per_scan.len();
339 let first_idx = code_per_scan.iter().position(Option::is_some);
340 let last_idx = code_per_scan.iter().rposition(Option::is_some);
341 match (first_idx, last_idx) {
342 (Some(f), Some(l)) if f > 0 && l == n - 1 => "added",
343 (Some(f), Some(l)) if f == 0 && l < n - 1 => "removed",
344 (Some(f), Some(l)) => {
345 let first_val = code_per_scan[f].unwrap_or(0);
346 if code_per_scan[f..=l]
347 .iter()
348 .all(|v| v.is_none_or(|x| x == first_val))
349 {
350 "unchanged"
351 } else {
352 "modified"
353 }
354 }
355 _ => "unchanged",
356 }
357}
358
359fn net_code_delta(code_per_scan: &[Option<i64>]) -> i64 {
361 let first_idx = code_per_scan.iter().position(Option::is_some);
362 let last_idx = code_per_scan.iter().rposition(Option::is_some);
363 match (first_idx, last_idx) {
364 (Some(f), Some(l)) => code_per_scan[l].unwrap_or(0) - code_per_scan[f].unwrap_or(0),
365 _ => 0,
366 }
367}
368
369#[must_use]
377#[allow(clippy::cast_precision_loss)]
378pub fn compute_multi_delta(runs: &[&AnalysisRun]) -> MultiScanComparison {
379 assert!(
380 runs.len() >= 2,
381 "compute_multi_delta requires at least 2 runs"
382 );
383
384 let all_paths: BTreeSet<String> = runs
386 .iter()
387 .flat_map(|r| r.per_file_records.iter().map(|f| f.relative_path.clone()))
388 .collect();
389
390 let run_maps: Vec<HashMap<&str, &FileRecord>> = runs
392 .iter()
393 .map(|r| {
394 r.per_file_records
395 .iter()
396 .map(|f| (f.relative_path.as_str(), f))
397 .collect()
398 })
399 .collect();
400
401 let mut file_matrix: Vec<MultiFileDelta> = all_paths
403 .into_iter()
404 .map(|path| {
405 let code_per_scan: Vec<Option<i64>> = run_maps
406 .iter()
407 .map(|m| {
408 m.get(path.as_str())
409 .map(|r| r.effective_counts.code_lines.cast_signed())
410 })
411 .collect();
412 let code_delta_per_scan = sequential_code_deltas(&code_per_scan);
413 let overall_status = classify_file_status(&code_per_scan).to_string();
414 let total_code_delta = net_code_delta(&code_per_scan);
415 let language = run_maps.iter().find_map(|m| {
416 m.get(path.as_str())
417 .and_then(|r| r.language)
418 .map(|l| l.display_name().to_string())
419 });
420 MultiFileDelta {
421 relative_path: path,
422 language,
423 code_per_scan,
424 code_delta_per_scan,
425 overall_status,
426 total_code_delta,
427 }
428 })
429 .collect();
430
431 file_matrix.sort_by(|a, b| {
433 const fn status_order(s: &str) -> u8 {
434 match s.as_bytes() {
435 b"modified" => 0,
436 b"added" => 1,
437 b"removed" => 2,
438 _ => 3,
439 }
440 }
441 status_order(&a.overall_status)
442 .cmp(&status_order(&b.overall_status))
443 .then(a.relative_path.cmp(&b.relative_path))
444 });
445
446 let sequential_deltas: Vec<ScanComparison> = (0..runs.len() - 1)
448 .map(|i| compute_delta(runs[i], runs[i + 1]))
449 .collect();
450
451 let total_delta = compute_delta(runs[0], runs[runs.len() - 1]).summary;
453
454 let points: Vec<MultiScanPoint> = runs
456 .iter()
457 .map(|r| {
458 let s = &r.summary_totals;
459 let coverage_line_pct = if s.coverage_lines_found > 0 {
460 Some(
461 ((s.coverage_lines_hit as f64 / s.coverage_lines_found as f64 * 1000.0)
462 .round())
463 / 10.0,
464 )
465 } else {
466 None
467 };
468 MultiScanPoint {
469 run_id: r.tool.run_id.clone(),
470 timestamp: r.tool.timestamp_utc,
471 git_commit: r.git_commit_short.clone(),
472 git_branch: r.git_branch.clone(),
473 git_tags: r.git_tags.clone(),
474 git_nearest_tag: r.git_nearest_tag.clone(),
475 code_lines: s.code_lines.cast_signed(),
476 comment_lines: s.comment_lines.cast_signed(),
477 blank_lines: s.blank_lines.cast_signed(),
478 files_analyzed: s.files_analyzed.cast_signed(),
479 test_count: s.test_count.cast_signed(),
480 coverage_line_pct,
481 }
482 })
483 .collect();
484
485 MultiScanComparison {
486 points,
487 sequential_deltas,
488 total_delta,
489 file_matrix,
490 }
491}