1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Status {
9 Detonated,
11 Ticking,
13 Inert,
15}
16
17impl Status {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 Status::Detonated => "detonated",
21 Status::Ticking => "ticking",
22 Status::Inert => "inert",
23 }
24 }
25}
26
27impl std::fmt::Display for Status {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str(self.as_str())
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Fuse {
36 pub file: PathBuf,
38
39 pub line: usize,
41
42 pub tag: String,
44
45 #[serde(serialize_with = "serialize_naive_date")]
47 #[serde(deserialize_with = "deserialize_naive_date")]
48 pub date: NaiveDate,
49
50 pub owner: Option<String>,
52
53 pub message: String,
55
56 pub status: Status,
58
59 #[serde(skip_serializing_if = "Option::is_none", default)]
62 pub blamed_owner: Option<String>,
63}
64
65impl Fuse {
66 #[must_use]
69 pub fn days_from_today(&self, today: NaiveDate) -> i64 {
70 (self.date - today).num_days()
71 }
72
73 #[must_use]
75 pub fn is_detonated(&self) -> bool {
76 self.status == Status::Detonated
77 }
78
79 #[must_use]
81 pub fn is_ticking(&self) -> bool {
82 self.status == Status::Ticking
83 }
84
85 #[must_use]
87 pub fn is_inert(&self) -> bool {
88 self.status == Status::Inert
89 }
90
91 pub fn compute_status(date: NaiveDate, today: NaiveDate, fuse_days: u32) -> Status {
93 if date < today {
94 Status::Detonated
95 } else {
96 let days_remaining = (date - today).num_days();
97 if days_remaining <= fuse_days as i64 {
98 Status::Ticking
99 } else {
100 Status::Inert
101 }
102 }
103 }
104
105 pub fn location(&self) -> String {
107 format!("{}:{}", self.file.display(), self.line)
108 }
109
110 pub fn date_str(&self) -> String {
112 self.date.format("%Y-%m-%d").to_string()
113 }
114}
115
116fn serialize_naive_date<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
119where
120 S: serde::Serializer,
121{
122 serializer.serialize_str(&date.format("%Y-%m-%d").to_string())
123}
124
125fn deserialize_naive_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
126where
127 D: serde::Deserializer<'de>,
128{
129 let s = String::deserialize(deserializer)?;
130 NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(serde::de::Error::custom)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn date(s: &str) -> NaiveDate {
138 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
139 }
140
141 fn make_fuse(expiry: &str, status: Status) -> Fuse {
142 Fuse {
143 file: PathBuf::from("src/foo.rs"),
144 line: 10,
145 tag: "TODO".to_string(),
146 date: date(expiry),
147 owner: None,
148 message: "some message".to_string(),
149 status,
150 blamed_owner: None,
151 }
152 }
153
154 #[test]
155 fn test_status_detonated() {
156 let today = date("2025-06-01");
157 let status = Fuse::compute_status(date("2025-01-01"), today, 14);
158 assert_eq!(status, Status::Detonated);
159 }
160
161 #[test]
162 fn test_status_ticking_boundary() {
163 let today = date("2025-06-01");
164 let status = Fuse::compute_status(date("2025-06-15"), today, 14);
166 assert_eq!(status, Status::Ticking);
167 }
168
169 #[test]
170 fn test_status_ticking_within_window() {
171 let today = date("2025-06-01");
172 let status = Fuse::compute_status(date("2025-06-10"), today, 14);
173 assert_eq!(status, Status::Ticking);
174 }
175
176 #[test]
177 fn test_status_inert() {
178 let today = date("2025-06-01");
179 let status = Fuse::compute_status(date("2025-12-31"), today, 14);
180 assert_eq!(status, Status::Inert);
181 }
182
183 #[test]
184 fn test_status_expire_today_is_ticking() {
185 let today = date("2025-06-01");
189 let status = Fuse::compute_status(date("2025-06-01"), today, 0);
190 assert_eq!(status, Status::Ticking);
192 }
193
194 #[test]
195 fn test_days_from_today_positive() {
196 let today = date("2025-06-01");
197 let fuse = make_fuse("2025-06-11", Status::Inert);
198 assert_eq!(fuse.days_from_today(today), 10);
199 }
200
201 #[test]
202 fn test_days_from_today_negative() {
203 let today = date("2025-06-01");
204 let fuse = make_fuse("2025-05-20", Status::Detonated);
205 assert_eq!(fuse.days_from_today(today), -12);
206 }
207
208 #[test]
209 fn test_location() {
210 let fuse = make_fuse("2099-01-01", Status::Inert);
211 assert_eq!(fuse.location(), "src/foo.rs:10");
212 }
213
214 #[test]
215 fn test_date_str() {
216 let fuse = make_fuse("2099-03-15", Status::Inert);
217 assert_eq!(fuse.date_str(), "2099-03-15");
218 }
219
220 #[test]
221 fn test_is_detonated() {
222 let fuse = make_fuse("2020-01-01", Status::Detonated);
223 assert!(fuse.is_detonated());
224 assert!(!fuse.is_ticking());
225 }
226
227 #[test]
228 fn test_is_ticking() {
229 let fuse = make_fuse("2025-06-10", Status::Ticking);
230 assert!(fuse.is_ticking());
231 assert!(!fuse.is_detonated());
232 }
233
234 #[test]
235 fn test_is_inert() {
236 let fuse = make_fuse("2099-01-01", Status::Inert);
237 assert!(fuse.is_inert());
238 assert!(!fuse.is_detonated());
239 assert!(!fuse.is_ticking());
240 }
241
242 #[test]
243 fn test_status_display() {
244 assert_eq!(Status::Detonated.to_string(), "detonated");
245 assert_eq!(Status::Ticking.to_string(), "ticking");
246 assert_eq!(Status::Inert.to_string(), "inert");
247 }
248
249 #[test]
250 fn test_serde_roundtrip() {
251 let fuse = Fuse {
252 file: PathBuf::from("src/lib.rs"),
253 line: 99,
254 tag: "FIXME".to_string(),
255 date: date("2099-12-31"),
256 owner: Some("alice".to_string()),
257 message: "remove after upgrade".to_string(),
258 status: Status::Inert,
259 blamed_owner: None,
260 };
261 let json = serde_json::to_string(&fuse).unwrap();
262 assert!(json.contains("2099-12-31"));
263 assert!(json.contains("alice"));
264 let decoded: Fuse = serde_json::from_str(&json).unwrap();
265 assert_eq!(decoded.date, fuse.date);
266 assert_eq!(decoded.owner, fuse.owner);
267 assert_eq!(decoded.tag, fuse.tag);
268 }
269
270 #[test]
271 fn test_compute_status_zero_fuse_window() {
272 let today = date("2025-06-01");
273 let status = Fuse::compute_status(date("2025-06-02"), today, 0);
275 assert_eq!(status, Status::Inert);
276 }
277}