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::{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    /// 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    /// Total files across both scans (`files_added + files_removed + files_modified + files_unchanged`).
77    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 &current.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 = &current.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// ── Multi-point comparison ─────────────────────────────────────────────────────
276
277/// Summary metrics snapshot for one scan in a multi-point timeline.
278#[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/// Per-file code counts across N scan points.
295#[derive(Debug, Serialize)]
296pub struct MultiFileDelta {
297    pub relative_path: String,
298    pub language: Option<String>,
299    /// Code lines at each scan (`None` = file absent at that point).
300    pub code_per_scan: Vec<Option<i64>>,
301    /// Delta from previous scan; index 0 is always `None`.
302    pub code_delta_per_scan: Vec<Option<i64>>,
303    /// `"added"` | `"removed"` | `"modified"` | `"unchanged"`
304    pub overall_status: String,
305    /// Code delta from first presence to last presence.
306    pub total_code_delta: i64,
307}
308
309#[derive(Debug, Serialize)]
310pub struct MultiScanComparison {
311    pub points: Vec<MultiScanPoint>,
312    /// One `ScanComparison` per consecutive pair (length = N − 1).
313    pub sequential_deltas: Vec<ScanComparison>,
314    /// Overall delta from the first scan to the last scan.
315    pub total_delta: SummaryDelta,
316    /// All files × all scan points, sorted modified → added → removed → unchanged.
317    pub file_matrix: Vec<MultiFileDelta>,
318}
319
320/// Per-scan deltas for a single file path: delta[0] is always None (no previous), delta[i] is
321/// code[i] - code[i-1], or None when both sides are absent.
322fn 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
336/// Classify a file's lifetime across scans as "added", "removed", "modified", or "unchanged".
337fn 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
359/// Net code-line change from the first scan where the file appeared to the last.
360fn 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/// Compute a multi-point timeline comparison.
370///
371/// `runs` must be sorted chronologically (oldest first) and contain at least 2 elements.
372///
373/// # Panics
374///
375/// Panics if `runs` contains fewer than 2 elements.
376#[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    // Union of all file paths across every run.
385    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    // Per-run lookup: path → FileRecord.
391    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    // Build the file matrix.
402    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    // Sort: modified → added → removed → unchanged, then path.
432    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    // Sequential deltas (N - 1 pairs).
447    let sequential_deltas: Vec<ScanComparison> = (0..runs.len() - 1)
448        .map(|i| compute_delta(runs[i], runs[i + 1]))
449        .collect();
450
451    // Overall first-to-last delta.
452    let total_delta = compute_delta(runs[0], runs[runs.len() - 1]).summary;
453
454    // Build scan-point summaries.
455    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}