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    let baseline_map: HashMap<&str, &EffectiveCounts> = baseline
70        .per_file_records
71        .iter()
72        .map(|f| (f.relative_path.as_str(), &f.effective_counts))
73        .collect();
74
75    let current_paths: HashSet<&str> = current
76        .per_file_records
77        .iter()
78        .map(|f| f.relative_path.as_str())
79        .collect();
80
81    let mut file_deltas: Vec<FileDelta> = Vec::new();
82
83    for record in &current.per_file_records {
84        let path = record.relative_path.as_str();
85        let curr = &record.effective_counts;
86        let lang = record.language.map(|l| l.display_name().to_string());
87
88        if let Some(base) = baseline_map.get(path) {
89            let code_delta = curr.code_lines.cast_signed() - base.code_lines.cast_signed();
90            let comment_delta = curr.comment_lines.cast_signed() - base.comment_lines.cast_signed();
91            let blank_delta = curr.blank_lines.cast_signed() - base.blank_lines.cast_signed();
92            let status = if code_delta == 0 && comment_delta == 0 && blank_delta == 0 {
93                FileChangeStatus::Unchanged
94            } else {
95                FileChangeStatus::Modified
96            };
97            file_deltas.push(FileDelta {
98                relative_path: record.relative_path.clone(),
99                language: lang,
100                status,
101                baseline_code: base.code_lines.cast_signed(),
102                current_code: curr.code_lines.cast_signed(),
103                code_delta,
104                baseline_comment: base.comment_lines.cast_signed(),
105                current_comment: curr.comment_lines.cast_signed(),
106                comment_delta,
107                baseline_blank: base.blank_lines.cast_signed(),
108                current_blank: curr.blank_lines.cast_signed(),
109                blank_delta,
110                total_delta: code_delta + comment_delta + blank_delta,
111            });
112        } else {
113            let total = (curr.code_lines + curr.comment_lines + curr.blank_lines).cast_signed();
114            file_deltas.push(FileDelta {
115                relative_path: record.relative_path.clone(),
116                language: lang,
117                status: FileChangeStatus::Added,
118                baseline_code: 0,
119                current_code: curr.code_lines.cast_signed(),
120                code_delta: curr.code_lines.cast_signed(),
121                baseline_comment: 0,
122                current_comment: curr.comment_lines.cast_signed(),
123                comment_delta: curr.comment_lines.cast_signed(),
124                baseline_blank: 0,
125                current_blank: curr.blank_lines.cast_signed(),
126                blank_delta: curr.blank_lines.cast_signed(),
127                total_delta: total,
128            });
129        }
130    }
131
132    for record in &baseline.per_file_records {
133        if !current_paths.contains(record.relative_path.as_str()) {
134            let base = &record.effective_counts;
135            let lang = record.language.map(|l| l.display_name().to_string());
136            let total = (base.code_lines + base.comment_lines + base.blank_lines).cast_signed();
137            file_deltas.push(FileDelta {
138                relative_path: record.relative_path.clone(),
139                language: lang,
140                status: FileChangeStatus::Removed,
141                baseline_code: base.code_lines.cast_signed(),
142                current_code: 0,
143                code_delta: -(base.code_lines.cast_signed()),
144                baseline_comment: base.comment_lines.cast_signed(),
145                current_comment: 0,
146                comment_delta: -(base.comment_lines.cast_signed()),
147                baseline_blank: base.blank_lines.cast_signed(),
148                current_blank: 0,
149                blank_delta: -(base.blank_lines.cast_signed()),
150                total_delta: -total,
151            });
152        }
153    }
154
155    file_deltas.sort_by(|a, b| {
156        const fn order(s: FileChangeStatus) -> u8 {
157            match s {
158                FileChangeStatus::Modified => 0,
159                FileChangeStatus::Added => 1,
160                FileChangeStatus::Removed => 2,
161                FileChangeStatus::Unchanged => 3,
162            }
163        }
164        order(a.status)
165            .cmp(&order(b.status))
166            .then(a.relative_path.cmp(&b.relative_path))
167    });
168
169    let files_added = file_deltas
170        .iter()
171        .filter(|f| f.status == FileChangeStatus::Added)
172        .count();
173    let files_removed = file_deltas
174        .iter()
175        .filter(|f| f.status == FileChangeStatus::Removed)
176        .count();
177    let files_modified = file_deltas
178        .iter()
179        .filter(|f| f.status == FileChangeStatus::Modified)
180        .count();
181    let files_unchanged = file_deltas
182        .iter()
183        .filter(|f| f.status == FileChangeStatus::Unchanged)
184        .count();
185
186    let s = &current.summary_totals;
187    let b = &baseline.summary_totals;
188
189    ScanComparison {
190        summary: SummaryDelta {
191            baseline_run_id: baseline.tool.run_id.clone(),
192            current_run_id: current.tool.run_id.clone(),
193            baseline_timestamp: baseline.tool.timestamp_utc,
194            current_timestamp: current.tool.timestamp_utc,
195            baseline_files: b.files_analyzed,
196            current_files: s.files_analyzed,
197            files_analyzed_delta: s.files_analyzed.cast_signed() - b.files_analyzed.cast_signed(),
198            baseline_code: b.code_lines,
199            current_code: s.code_lines,
200            code_lines_delta: s.code_lines.cast_signed() - b.code_lines.cast_signed(),
201            baseline_comments: b.comment_lines,
202            current_comments: s.comment_lines,
203            comment_lines_delta: s.comment_lines.cast_signed() - b.comment_lines.cast_signed(),
204            blank_lines_delta: s.blank_lines.cast_signed() - b.blank_lines.cast_signed(),
205            total_lines_delta: s
206                .total_physical_lines
207                .cast_signed()
208                .wrapping_sub(b.total_physical_lines.cast_signed()),
209        },
210        file_deltas,
211        files_added,
212        files_removed,
213        files_modified,
214        files_unchanged,
215    }
216}