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};
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}
29
30#[derive(Debug, Serialize, PartialEq, Eq, Clone, Copy)]
31#[serde(rename_all = "snake_case")]
32pub enum FileChangeStatus {
33    Added,
34    Removed,
35    Modified,
36    Unchanged,
37}
38
39#[derive(Debug, Serialize)]
40pub struct FileDelta {
41    pub relative_path: String,
42    pub language: Option<String>,
43    pub status: FileChangeStatus,
44    pub baseline_code: i64,
45    pub current_code: i64,
46    pub code_delta: i64,
47    pub baseline_comment: i64,
48    pub current_comment: i64,
49    pub comment_delta: i64,
50    pub baseline_blank: i64,
51    pub current_blank: i64,
52    pub blank_delta: i64,
53    pub total_delta: i64,
54}
55
56#[derive(Debug, Serialize)]
57pub struct ScanComparison {
58    pub summary: SummaryDelta,
59    pub file_deltas: Vec<FileDelta>,
60    pub files_added: usize,
61    pub files_removed: usize,
62    pub files_modified: usize,
63    pub files_unchanged: usize,
64}
65
66#[must_use]
67#[allow(clippy::too_many_lines)]
68pub fn compute_delta(baseline: &AnalysisRun, current: &AnalysisRun) -> ScanComparison {
69    // NOSONAR(rust:S3776)
70    let baseline_map: HashMap<&str, &EffectiveCounts> = baseline
71        .per_file_records
72        .iter()
73        .map(|f| (f.relative_path.as_str(), &f.effective_counts))
74        .collect();
75
76    let current_paths: HashSet<&str> = current
77        .per_file_records
78        .iter()
79        .map(|f| f.relative_path.as_str())
80        .collect();
81
82    let mut file_deltas: Vec<FileDelta> = Vec::new();
83
84    for record in &current.per_file_records {
85        let path = record.relative_path.as_str();
86        let curr = &record.effective_counts;
87        let lang = record.language.map(|l| l.display_name().to_string());
88
89        if let Some(base) = baseline_map.get(path) {
90            let code_delta = curr.code_lines.cast_signed() - base.code_lines.cast_signed();
91            let comment_delta = curr.comment_lines.cast_signed() - base.comment_lines.cast_signed();
92            let blank_delta = curr.blank_lines.cast_signed() - base.blank_lines.cast_signed();
93            let status = if code_delta == 0 && comment_delta == 0 && blank_delta == 0 {
94                FileChangeStatus::Unchanged
95            } else {
96                FileChangeStatus::Modified
97            };
98            file_deltas.push(FileDelta {
99                relative_path: record.relative_path.clone(),
100                language: lang,
101                status,
102                baseline_code: base.code_lines.cast_signed(),
103                current_code: curr.code_lines.cast_signed(),
104                code_delta,
105                baseline_comment: base.comment_lines.cast_signed(),
106                current_comment: curr.comment_lines.cast_signed(),
107                comment_delta,
108                baseline_blank: base.blank_lines.cast_signed(),
109                current_blank: curr.blank_lines.cast_signed(),
110                blank_delta,
111                total_delta: code_delta + comment_delta + blank_delta,
112            });
113        } else {
114            let total = (curr.code_lines + curr.comment_lines + curr.blank_lines).cast_signed();
115            file_deltas.push(FileDelta {
116                relative_path: record.relative_path.clone(),
117                language: lang,
118                status: FileChangeStatus::Added,
119                baseline_code: 0,
120                current_code: curr.code_lines.cast_signed(),
121                code_delta: curr.code_lines.cast_signed(),
122                baseline_comment: 0,
123                current_comment: curr.comment_lines.cast_signed(),
124                comment_delta: curr.comment_lines.cast_signed(),
125                baseline_blank: 0,
126                current_blank: curr.blank_lines.cast_signed(),
127                blank_delta: curr.blank_lines.cast_signed(),
128                total_delta: total,
129            });
130        }
131    }
132
133    for record in &baseline.per_file_records {
134        if !current_paths.contains(record.relative_path.as_str()) {
135            let base = &record.effective_counts;
136            let lang = record.language.map(|l| l.display_name().to_string());
137            let total = (base.code_lines + base.comment_lines + base.blank_lines).cast_signed();
138            file_deltas.push(FileDelta {
139                relative_path: record.relative_path.clone(),
140                language: lang,
141                status: FileChangeStatus::Removed,
142                baseline_code: base.code_lines.cast_signed(),
143                current_code: 0,
144                code_delta: -(base.code_lines.cast_signed()),
145                baseline_comment: base.comment_lines.cast_signed(),
146                current_comment: 0,
147                comment_delta: -(base.comment_lines.cast_signed()),
148                baseline_blank: base.blank_lines.cast_signed(),
149                current_blank: 0,
150                blank_delta: -(base.blank_lines.cast_signed()),
151                total_delta: -total,
152            });
153        }
154    }
155
156    file_deltas.sort_by(|a, b| {
157        const fn order(s: FileChangeStatus) -> u8 {
158            match s {
159                FileChangeStatus::Modified => 0,
160                FileChangeStatus::Added => 1,
161                FileChangeStatus::Removed => 2,
162                FileChangeStatus::Unchanged => 3,
163            }
164        }
165        order(a.status)
166            .cmp(&order(b.status))
167            .then(a.relative_path.cmp(&b.relative_path))
168    });
169
170    let files_added = file_deltas
171        .iter()
172        .filter(|f| f.status == FileChangeStatus::Added)
173        .count();
174    let files_removed = file_deltas
175        .iter()
176        .filter(|f| f.status == FileChangeStatus::Removed)
177        .count();
178    let files_modified = file_deltas
179        .iter()
180        .filter(|f| f.status == FileChangeStatus::Modified)
181        .count();
182    let files_unchanged = file_deltas
183        .iter()
184        .filter(|f| f.status == FileChangeStatus::Unchanged)
185        .count();
186
187    let s = &current.summary_totals;
188    let b = &baseline.summary_totals;
189
190    ScanComparison {
191        summary: SummaryDelta {
192            baseline_run_id: baseline.tool.run_id.clone(),
193            current_run_id: current.tool.run_id.clone(),
194            baseline_timestamp: baseline.tool.timestamp_utc,
195            current_timestamp: current.tool.timestamp_utc,
196            baseline_files: b.files_analyzed,
197            current_files: s.files_analyzed,
198            files_analyzed_delta: s.files_analyzed.cast_signed() - b.files_analyzed.cast_signed(),
199            baseline_code: b.code_lines,
200            current_code: s.code_lines,
201            code_lines_delta: s.code_lines.cast_signed() - b.code_lines.cast_signed(),
202            baseline_comments: b.comment_lines,
203            current_comments: s.comment_lines,
204            comment_lines_delta: s.comment_lines.cast_signed() - b.comment_lines.cast_signed(),
205            blank_lines_delta: s.blank_lines.cast_signed() - b.blank_lines.cast_signed(),
206            total_lines_delta: s
207                .total_physical_lines
208                .cast_signed()
209                .wrapping_sub(b.total_physical_lines.cast_signed()),
210        },
211        file_deltas,
212        files_added,
213        files_removed,
214        files_modified,
215        files_unchanged,
216    }
217}