use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Detonated,
Ticking,
Inert,
}
impl Status {
pub fn as_str(&self) -> &'static str {
match self {
Status::Detonated => "detonated",
Status::Ticking => "ticking",
Status::Inert => "inert",
}
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fuse {
pub file: PathBuf,
pub line: usize,
pub tag: String,
#[serde(serialize_with = "serialize_naive_date")]
#[serde(deserialize_with = "deserialize_naive_date")]
pub date: NaiveDate,
pub owner: Option<String>,
pub message: String,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub blamed_owner: Option<String>,
}
impl Fuse {
#[must_use]
pub fn days_from_today(&self, today: NaiveDate) -> i64 {
(self.date - today).num_days()
}
#[must_use]
pub fn is_detonated(&self) -> bool {
self.status == Status::Detonated
}
#[must_use]
pub fn is_ticking(&self) -> bool {
self.status == Status::Ticking
}
#[must_use]
pub fn is_inert(&self) -> bool {
self.status == Status::Inert
}
pub fn compute_status(date: NaiveDate, today: NaiveDate, fuse_days: u32) -> Status {
if date < today {
Status::Detonated
} else {
let days_remaining = (date - today).num_days();
if days_remaining <= fuse_days as i64 {
Status::Ticking
} else {
Status::Inert
}
}
}
pub fn location(&self) -> String {
format!("{}:{}", self.file.display(), self.line)
}
pub fn date_str(&self) -> String {
self.date.format("%Y-%m-%d").to_string()
}
}
fn serialize_naive_date<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&date.format("%Y-%m-%d").to_string())
}
fn deserialize_naive_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(serde::de::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
fn make_fuse(expiry: &str, status: Status) -> Fuse {
Fuse {
file: PathBuf::from("src/foo.rs"),
line: 10,
tag: "TODO".to_string(),
date: date(expiry),
owner: None,
message: "some message".to_string(),
status,
blamed_owner: None,
}
}
#[test]
fn test_status_detonated() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-01-01"), today, 14);
assert_eq!(status, Status::Detonated);
}
#[test]
fn test_status_ticking_boundary() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-06-15"), today, 14);
assert_eq!(status, Status::Ticking);
}
#[test]
fn test_status_ticking_within_window() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-06-10"), today, 14);
assert_eq!(status, Status::Ticking);
}
#[test]
fn test_status_inert() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-12-31"), today, 14);
assert_eq!(status, Status::Inert);
}
#[test]
fn test_status_expire_today_is_ticking() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-06-01"), today, 0);
assert_eq!(status, Status::Ticking);
}
#[test]
fn test_days_from_today_positive() {
let today = date("2025-06-01");
let fuse = make_fuse("2025-06-11", Status::Inert);
assert_eq!(fuse.days_from_today(today), 10);
}
#[test]
fn test_days_from_today_negative() {
let today = date("2025-06-01");
let fuse = make_fuse("2025-05-20", Status::Detonated);
assert_eq!(fuse.days_from_today(today), -12);
}
#[test]
fn test_location() {
let fuse = make_fuse("2099-01-01", Status::Inert);
assert_eq!(fuse.location(), "src/foo.rs:10");
}
#[test]
fn test_date_str() {
let fuse = make_fuse("2099-03-15", Status::Inert);
assert_eq!(fuse.date_str(), "2099-03-15");
}
#[test]
fn test_is_detonated() {
let fuse = make_fuse("2020-01-01", Status::Detonated);
assert!(fuse.is_detonated());
assert!(!fuse.is_ticking());
}
#[test]
fn test_is_ticking() {
let fuse = make_fuse("2025-06-10", Status::Ticking);
assert!(fuse.is_ticking());
assert!(!fuse.is_detonated());
}
#[test]
fn test_is_inert() {
let fuse = make_fuse("2099-01-01", Status::Inert);
assert!(fuse.is_inert());
assert!(!fuse.is_detonated());
assert!(!fuse.is_ticking());
}
#[test]
fn test_status_display() {
assert_eq!(Status::Detonated.to_string(), "detonated");
assert_eq!(Status::Ticking.to_string(), "ticking");
assert_eq!(Status::Inert.to_string(), "inert");
}
#[test]
fn test_serde_roundtrip() {
let fuse = Fuse {
file: PathBuf::from("src/lib.rs"),
line: 99,
tag: "FIXME".to_string(),
date: date("2099-12-31"),
owner: Some("alice".to_string()),
message: "remove after upgrade".to_string(),
status: Status::Inert,
blamed_owner: None,
};
let json = serde_json::to_string(&fuse).unwrap();
assert!(json.contains("2099-12-31"));
assert!(json.contains("alice"));
let decoded: Fuse = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.date, fuse.date);
assert_eq!(decoded.owner, fuse.owner);
assert_eq!(decoded.tag, fuse.tag);
}
#[test]
fn test_compute_status_zero_fuse_window() {
let today = date("2025-06-01");
let status = Fuse::compute_status(date("2025-06-02"), today, 0);
assert_eq!(status, Status::Inert);
}
}