Skip to main content

sloc_core/
baseline.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4//! Named baseline snapshots — save a scan result as a pinned reference point
5//! and compare future scans against it.
6//!
7//! Baselines are stored in `baselines.json` alongside the scan registry and
8//! can be managed via `--set-baseline` / `--fail-above-baseline` CLI flags.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17use crate::ScanSummarySnapshot;
18
19fn default_baselines_path() -> PathBuf {
20    // Mirror the convention used by the web server: relative paths resolve
21    // against the current working directory.
22    PathBuf::from("out").join("baselines.json")
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BaselineEntry {
27    pub name: String,
28    pub saved_at: DateTime<Utc>,
29    pub run_id: String,
30    pub summary: ScanSummarySnapshot,
31    /// Path to the full JSON artifact if still on disk.
32    pub json_path: Option<PathBuf>,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct BaselineStore {
37    pub baselines: BTreeMap<String, BaselineEntry>,
38}
39
40impl BaselineStore {
41    #[must_use]
42    pub fn load(path: &Path) -> Self {
43        std::fs::read_to_string(path)
44            .ok()
45            .and_then(|s| serde_json::from_str(&s).ok())
46            .unwrap_or_default()
47    }
48
49    /// # Errors
50    ///
51    /// Returns an error if the file cannot be serialized or written.
52    pub fn save(&self, path: &Path) -> Result<()> {
53        if let Some(parent) = path.parent() {
54            std::fs::create_dir_all(parent)
55                .with_context(|| format!("failed to create {}", parent.display()))?;
56        }
57        let json = serde_json::to_string_pretty(self).context("failed to serialize baselines")?;
58        std::fs::write(path, json).with_context(|| format!("failed to write {}", path.display()))
59    }
60
61    pub fn set(&mut self, entry: BaselineEntry) {
62        self.baselines.insert(entry.name.clone(), entry);
63    }
64
65    #[must_use]
66    pub fn get(&self, name: &str) -> Option<&BaselineEntry> {
67        self.baselines.get(name)
68    }
69
70    pub fn remove(&mut self, name: &str) -> bool {
71        self.baselines.remove(name).is_some()
72    }
73}
74
75/// Returns the path to the baselines file, honouring `SLOC_BASELINES_PATH`.
76pub fn resolve_baselines_path() -> PathBuf {
77    std::env::var("SLOC_BASELINES_PATH").map_or_else(|_| default_baselines_path(), PathBuf::from)
78}
79
80/// Result of a baseline comparison check.
81pub struct BaselineCheckResult {
82    pub baseline_name: String,
83    pub baseline_code_lines: u64,
84    pub current_code_lines: u64,
85    pub delta: i64,
86    pub delta_pct: f64,
87    pub exceeded: bool,
88    pub max_delta_pct: Option<f64>,
89}
90
91impl BaselineCheckResult {
92    pub fn print_summary(&self) {
93        let sign = if self.delta >= 0 { "+" } else { "" };
94        eprintln!(
95            "baseline '{}': baseline={} current={} delta={}{} ({:+.1}%)",
96            self.baseline_name,
97            self.baseline_code_lines,
98            self.current_code_lines,
99            sign,
100            self.delta,
101            self.delta_pct,
102        );
103        if self.exceeded {
104            eprintln!(
105                "error: code growth {:.1}% exceeds --max-delta-pct {:.1}% (--fail-above-baseline)",
106                self.delta_pct,
107                self.max_delta_pct.unwrap_or(0.0)
108            );
109        }
110    }
111}
112
113/// Compare `current_code_lines` to a stored baseline.
114///
115/// # Errors
116/// Returns an error if the named baseline does not exist.
117pub fn check_against_baseline(
118    store: &BaselineStore,
119    name: &str,
120    current_code_lines: u64,
121    max_delta_pct: Option<f64>,
122) -> Result<BaselineCheckResult> {
123    let entry = store.get(name).ok_or_else(|| {
124        anyhow::anyhow!("baseline '{name}' not found; use --set-baseline to create it")
125    })?;
126
127    let baseline_code = entry.summary.code_lines;
128    // Signed delta: values are line counts bounded well within i64 range.
129    #[allow(clippy::cast_possible_wrap)] // line counts fit in i64
130    let delta = current_code_lines as i64 - baseline_code as i64;
131    let delta_pct = if baseline_code == 0 {
132        0.0
133    } else {
134        // ratio/percentage display, precision loss acceptable
135        #[allow(clippy::cast_precision_loss)]
136        let result = (delta as f64 / baseline_code as f64) * 100.0;
137        result
138    };
139
140    let exceeded = max_delta_pct.is_some_and(|limit| delta_pct > limit);
141
142    Ok(BaselineCheckResult {
143        baseline_name: name.to_owned(),
144        baseline_code_lines: baseline_code,
145        current_code_lines,
146        delta,
147        delta_pct,
148        exceeded,
149        max_delta_pct,
150    })
151}