use crate::config::Config;
use crate::error::{Error, Result};
use crate::scanner::scan;
use chrono::NaiveDate;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub generated_at: String,
pub detonated: usize,
pub ticking: usize,
}
pub fn load_baseline(path: &Path) -> Result<Option<Baseline>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
source: e,
path: Some(path.to_path_buf()),
})?;
let baseline: Baseline = serde_json::from_str(&content).map_err(|e| {
Error::InvalidArgument(format!(
"failed to parse baseline file '{}': {}",
path.display(),
e
))
})?;
Ok(Some(baseline))
}
pub fn save_baseline(baseline: &Baseline, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(baseline)
.map_err(|e| Error::InvalidArgument(format!("failed to serialize baseline: {}", e)))?;
std::fs::write(path, json).map_err(|e| Error::Io {
source: e,
path: Some(path.to_path_buf()),
})
}
pub fn run_baseline_save(
scan_path: &Path,
cfg: &Config,
today: NaiveDate,
baseline_path: &Path,
generated_at: &str,
) -> Result<i32> {
let result = scan(scan_path, cfg, today)?;
let detonated = result.detonated().len();
let ticking = result.ticking().len();
let baseline = Baseline {
generated_at: generated_at.to_string(),
detonated,
ticking,
};
save_baseline(&baseline, baseline_path)?;
println!(
"baseline saved to '{}': detonated={}, ticking={}",
baseline_path.display(),
detonated,
ticking
);
Ok(0)
}
pub fn run_baseline_show(
scan_path: &Path,
cfg: &Config,
today: NaiveDate,
baseline_path: &Path,
) -> Result<i32> {
let result = scan(scan_path, cfg, today)?;
let current_detonated = result.detonated().len();
let current_ticking = result.ticking().len();
let baseline = load_baseline(baseline_path)?;
match baseline {
None => {
println!("{:>21} (no baseline saved)", "current");
println!("{:<16} {:>7}", "detonated", current_detonated);
println!("{:<16} {:>7}", "ticking", current_ticking);
}
Some(ref b) => {
println!("{:>21} {:>8}", "current", "baseline");
let detonated_current_str = current_detonated.to_string();
let detonated_baseline_str = b.detonated.to_string();
if current_detonated > b.detonated {
println!(
"{:<16} {:>7} {:>8}",
"detonated",
detonated_current_str.red().bold(),
detonated_baseline_str
);
} else {
println!(
"{:<16} {:>7} {:>8}",
"detonated", detonated_current_str, detonated_baseline_str
);
}
let ticking_current_str = current_ticking.to_string();
let ticking_baseline_str = b.ticking.to_string();
if current_ticking > b.ticking {
println!(
"{:<16} {:>7} {:>8}",
"ticking",
ticking_current_str.red().bold(),
ticking_baseline_str
);
} else {
println!(
"{:<16} {:>7} {:>8}",
"ticking", ticking_current_str, ticking_baseline_str
);
}
}
}
Ok(0)
}
pub fn check_ratchet(
detonated: usize,
ticking: usize,
baseline: Option<&Baseline>,
max_detonated: Option<usize>,
max_ticking: Option<usize>,
) -> Vec<String> {
let mut violations: Vec<String> = Vec::new();
if let Some(limit) = max_detonated {
if detonated > limit {
violations.push(format!(
"detonated count {} exceeds max_detonated limit of {}",
detonated, limit
));
}
}
if let Some(limit) = max_ticking {
if ticking > limit {
violations.push(format!(
"ticking count {} exceeds max_ticking limit of {}",
ticking, limit
));
}
}
if let Some(b) = baseline {
if detonated > b.detonated {
violations.push(format!(
"detonated count {} exceeds baseline of {} — ratchet violated",
detonated, b.detonated
));
}
if ticking > b.ticking {
violations.push(format!(
"ticking count {} exceeds baseline of {} — ratchet violated",
ticking, b.ticking
));
}
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_check_ratchet_no_baseline_no_max() {
let violations = check_ratchet(5, 10, None, None, None);
assert!(violations.is_empty());
}
#[test]
fn test_check_ratchet_max_detonated_violated() {
let violations = check_ratchet(3, 0, None, Some(2), None);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("max_detonated limit of 2"));
}
#[test]
fn test_check_ratchet_max_detonated_at_limit_ok() {
let violations = check_ratchet(2, 0, None, Some(2), None);
assert!(violations.is_empty());
}
#[test]
fn test_check_ratchet_baseline_detonated_exceeded() {
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 2,
ticking: 0,
};
let violations = check_ratchet(3, 0, Some(&baseline), None, None);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("ratchet violated"));
assert!(violations[0].contains("baseline of 2"));
}
#[test]
fn test_check_ratchet_baseline_improved_ok() {
let baseline = Baseline {
generated_at: "2025-01-01T00:00:00Z".to_string(),
detonated: 5,
ticking: 0,
};
let violations = check_ratchet(3, 0, Some(&baseline), None, None);
assert!(violations.is_empty());
}
#[test]
fn test_check_ratchet_ticking_violated() {
let violations = check_ratchet(0, 15, None, None, Some(10));
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("max_ticking limit of 10"));
}
#[test]
fn test_check_ratchet_multiple_violations() {
let violations = check_ratchet(5, 20, None, Some(3), Some(10));
assert_eq!(violations.len(), 2);
}
#[test]
fn test_load_baseline_nonexistent_returns_none() {
let result = load_baseline(std::path::Path::new(
"/nonexistent/path/.timebomb-baseline.json",
));
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_load_baseline_invalid_json_returns_err() {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "this is not valid json {{{{").unwrap();
let result = load_baseline(f.path());
assert!(result.is_err());
}
#[test]
fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let baseline_path = dir.path().join("baseline.json");
let baseline = Baseline {
generated_at: "2025-06-01T12:00:00Z".to_string(),
detonated: 3,
ticking: 7,
};
save_baseline(&baseline, &baseline_path).unwrap();
let loaded = load_baseline(&baseline_path).unwrap().unwrap();
assert_eq!(loaded.generated_at, baseline.generated_at);
assert_eq!(loaded.detonated, baseline.detonated);
assert_eq!(loaded.ticking, baseline.ticking);
}
}