1use 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 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 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 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
75pub fn resolve_baselines_path() -> PathBuf {
77 std::env::var("SLOC_BASELINES_PATH").map_or_else(|_| default_baselines_path(), PathBuf::from)
78}
79
80pub 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
113pub 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 #[allow(clippy::cast_possible_wrap)] 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 #[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}