Skip to main content

sloc_core/
delta.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use 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    /// Lines hit delta (positive = more covered). `None` if neither run has coverage data.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub coverage_lines_hit_delta: Option<i64>,
31    /// Line coverage percentage delta (positive = improved). `None` if neither run has coverage.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub coverage_line_pct_delta: Option<f64>,
34    /// Baseline line coverage percentage. `None` if baseline had no coverage data.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub baseline_coverage_line_pct: Option<f64>,
37    /// Current line coverage percentage. `None` if current has no coverage data.
38    #[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 &current.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 = &current.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}