Skip to main content

timebomb/
baseline.rs

1//! Baseline save/show/ratchet enforcement for `timebomb bunker` subcommand.
2//!
3//! "today" is always injected — never fetched internally.
4//! "generated_at" is always injected from main.rs — never fetched internally.
5
6use 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/// The baseline snapshot stored in `.timebomb-baseline.json`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Baseline {
17    /// RFC 3339 timestamp of when this baseline was saved (set by the caller in main.rs).
18    pub generated_at: String,
19    /// Number of detonated fuses at the time the baseline was saved.
20    pub detonated: usize,
21    /// Number of ticking fuses at the time the baseline was saved.
22    pub ticking: usize,
23}
24
25/// Load the baseline from a JSON file.
26///
27/// Returns `Ok(None)` if the file does not exist.
28/// Returns `Err` if the file exists but cannot be read or parsed.
29pub 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
47/// Write a baseline to a JSON file.
48pub 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
57/// Scan, count, write baseline, print confirmation. Returns exit code 0 on success.
58pub 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
87/// Scan, load baseline (if any), print comparison table. Returns exit code 0 on success.
88pub 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            // Print table header
108            println!("{:>21}  {:>8}", "current", "baseline");
109
110            // detonated row — highlight in red if current exceeds baseline
111            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            // ticking row — highlight in red if current exceeds baseline
128            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
149/// Pure ratchet check — no I/O.
150///
151/// All four constraints are checked independently and all violations are reported.
152/// Returns an empty vec if no violations are found.
153pub 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    // Config limit checks
163    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    // Baseline ratchet checks
182    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    // ─── check_ratchet unit tests ─────────────────────────────────────────────
206
207    #[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    // ─── load_baseline tests ──────────────────────────────────────────────────
264
265    #[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}