1use crate::config::Config;
7use crate::error::{Error, Result};
8use crate::scanner::scan;
9use chrono::NaiveDate;
10use colored::Colorize;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Baseline {
17 pub generated_at: String,
19 pub detonated: usize,
21 pub ticking: usize,
23}
24
25pub fn load_baseline(path: &Path) -> Result<Option<Baseline>> {
30 if !path.exists() {
31 return Ok(None);
32 }
33 let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
34 source: e,
35 path: Some(path.to_path_buf()),
36 })?;
37 let baseline: Baseline = serde_json::from_str(&content).map_err(|e| {
38 Error::InvalidArgument(format!(
39 "failed to parse baseline file '{}': {}",
40 path.display(),
41 e
42 ))
43 })?;
44 Ok(Some(baseline))
45}
46
47pub fn save_baseline(baseline: &Baseline, path: &Path) -> Result<()> {
49 let json = serde_json::to_string_pretty(baseline)
50 .map_err(|e| Error::InvalidArgument(format!("failed to serialize baseline: {}", e)))?;
51 std::fs::write(path, json).map_err(|e| Error::Io {
52 source: e,
53 path: Some(path.to_path_buf()),
54 })
55}
56
57pub fn run_baseline_save(
59 scan_path: &Path,
60 cfg: &Config,
61 today: NaiveDate,
62 baseline_path: &Path,
63 generated_at: &str,
64) -> Result<i32> {
65 let result = scan(scan_path, cfg, today)?;
66 let detonated = result.detonated().len();
67 let ticking = result.ticking().len();
68
69 let baseline = Baseline {
70 generated_at: generated_at.to_string(),
71 detonated,
72 ticking,
73 };
74
75 save_baseline(&baseline, baseline_path)?;
76
77 println!(
78 "baseline saved to '{}': detonated={}, ticking={}",
79 baseline_path.display(),
80 detonated,
81 ticking
82 );
83
84 Ok(0)
85}
86
87pub fn run_baseline_show(
89 scan_path: &Path,
90 cfg: &Config,
91 today: NaiveDate,
92 baseline_path: &Path,
93) -> Result<i32> {
94 let result = scan(scan_path, cfg, today)?;
95 let current_detonated = result.detonated().len();
96 let current_ticking = result.ticking().len();
97
98 let baseline = load_baseline(baseline_path)?;
99
100 match baseline {
101 None => {
102 println!("{:>21} (no baseline saved)", "current");
103 println!("{:<16} {:>7}", "detonated", current_detonated);
104 println!("{:<16} {:>7}", "ticking", current_ticking);
105 }
106 Some(ref b) => {
107 println!("{:>21} {:>8}", "current", "baseline");
109
110 let detonated_current_str = current_detonated.to_string();
112 let detonated_baseline_str = b.detonated.to_string();
113 if current_detonated > b.detonated {
114 println!(
115 "{:<16} {:>7} {:>8}",
116 "detonated",
117 detonated_current_str.red().bold(),
118 detonated_baseline_str
119 );
120 } else {
121 println!(
122 "{:<16} {:>7} {:>8}",
123 "detonated", detonated_current_str, detonated_baseline_str
124 );
125 }
126
127 let ticking_current_str = current_ticking.to_string();
129 let ticking_baseline_str = b.ticking.to_string();
130 if current_ticking > b.ticking {
131 println!(
132 "{:<16} {:>7} {:>8}",
133 "ticking",
134 ticking_current_str.red().bold(),
135 ticking_baseline_str
136 );
137 } else {
138 println!(
139 "{:<16} {:>7} {:>8}",
140 "ticking", ticking_current_str, ticking_baseline_str
141 );
142 }
143 }
144 }
145
146 Ok(0)
147}
148
149pub fn check_ratchet(
154 detonated: usize,
155 ticking: usize,
156 baseline: Option<&Baseline>,
157 max_detonated: Option<usize>,
158 max_ticking: Option<usize>,
159) -> Vec<String> {
160 let mut violations: Vec<String> = Vec::new();
161
162 if let Some(limit) = max_detonated {
164 if detonated > limit {
165 violations.push(format!(
166 "detonated count {} exceeds max_detonated limit of {}",
167 detonated, limit
168 ));
169 }
170 }
171
172 if let Some(limit) = max_ticking {
173 if ticking > limit {
174 violations.push(format!(
175 "ticking count {} exceeds max_ticking limit of {}",
176 ticking, limit
177 ));
178 }
179 }
180
181 if let Some(b) = baseline {
183 if detonated > b.detonated {
184 violations.push(format!(
185 "detonated count {} exceeds baseline of {} — ratchet violated",
186 detonated, b.detonated
187 ));
188 }
189 if ticking > b.ticking {
190 violations.push(format!(
191 "ticking count {} exceeds baseline of {} — ratchet violated",
192 ticking, b.ticking
193 ));
194 }
195 }
196
197 violations
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::io::Write;
204
205 #[test]
208 fn test_check_ratchet_no_baseline_no_max() {
209 let violations = check_ratchet(5, 10, None, None, None);
210 assert!(violations.is_empty());
211 }
212
213 #[test]
214 fn test_check_ratchet_max_detonated_violated() {
215 let violations = check_ratchet(3, 0, None, Some(2), None);
216 assert_eq!(violations.len(), 1);
217 assert!(violations[0].contains("max_detonated limit of 2"));
218 }
219
220 #[test]
221 fn test_check_ratchet_max_detonated_at_limit_ok() {
222 let violations = check_ratchet(2, 0, None, Some(2), None);
223 assert!(violations.is_empty());
224 }
225
226 #[test]
227 fn test_check_ratchet_baseline_detonated_exceeded() {
228 let baseline = Baseline {
229 generated_at: "2025-01-01T00:00:00Z".to_string(),
230 detonated: 2,
231 ticking: 0,
232 };
233 let violations = check_ratchet(3, 0, Some(&baseline), None, None);
234 assert_eq!(violations.len(), 1);
235 assert!(violations[0].contains("ratchet violated"));
236 assert!(violations[0].contains("baseline of 2"));
237 }
238
239 #[test]
240 fn test_check_ratchet_baseline_improved_ok() {
241 let baseline = Baseline {
242 generated_at: "2025-01-01T00:00:00Z".to_string(),
243 detonated: 5,
244 ticking: 0,
245 };
246 let violations = check_ratchet(3, 0, Some(&baseline), None, None);
247 assert!(violations.is_empty());
248 }
249
250 #[test]
251 fn test_check_ratchet_ticking_violated() {
252 let violations = check_ratchet(0, 15, None, None, Some(10));
253 assert_eq!(violations.len(), 1);
254 assert!(violations[0].contains("max_ticking limit of 10"));
255 }
256
257 #[test]
258 fn test_check_ratchet_multiple_violations() {
259 let violations = check_ratchet(5, 20, None, Some(3), Some(10));
260 assert_eq!(violations.len(), 2);
261 }
262
263 #[test]
266 fn test_load_baseline_nonexistent_returns_none() {
267 let result = load_baseline(std::path::Path::new(
268 "/nonexistent/path/.timebomb-baseline.json",
269 ));
270 assert!(result.is_ok());
271 assert!(result.unwrap().is_none());
272 }
273
274 #[test]
275 fn test_load_baseline_invalid_json_returns_err() {
276 let mut f = tempfile::NamedTempFile::new().unwrap();
277 write!(f, "this is not valid json {{{{").unwrap();
278 let result = load_baseline(f.path());
279 assert!(result.is_err());
280 }
281
282 #[test]
283 fn test_save_and_load_roundtrip() {
284 let dir = tempfile::tempdir().unwrap();
285 let baseline_path = dir.path().join("baseline.json");
286
287 let baseline = Baseline {
288 generated_at: "2025-06-01T12:00:00Z".to_string(),
289 detonated: 3,
290 ticking: 7,
291 };
292
293 save_baseline(&baseline, &baseline_path).unwrap();
294 let loaded = load_baseline(&baseline_path).unwrap().unwrap();
295
296 assert_eq!(loaded.generated_at, baseline.generated_at);
297 assert_eq!(loaded.detonated, baseline.detonated);
298 assert_eq!(loaded.ticking, baseline.ticking);
299 }
300}