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