Skip to main content

timebomb/
annotation.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// The status of a fuse relative to a given "today" date.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Status {
9    /// The deadline has already passed.
10    Detonated,
11    /// The deadline is within the configured warning window.
12    Ticking,
13    /// The deadline is comfortably in the future.
14    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/// A single timebomb fuse found in a source file.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Fuse {
36    /// Path to the file containing the fuse (relative to scan root).
37    pub file: PathBuf,
38
39    /// 1-based line number within the file.
40    pub line: usize,
41
42    /// The tag keyword, e.g. "TODO", "FIXME", "HACK".
43    pub tag: String,
44
45    /// The expiry date parsed from the fuse.
46    #[serde(serialize_with = "serialize_naive_date")]
47    #[serde(deserialize_with = "deserialize_naive_date")]
48    pub date: NaiveDate,
49
50    /// Optional owner extracted from the second bracket group, e.g. `[alice]`.
51    pub owner: Option<String>,
52
53    /// The descriptive message after the colon.
54    pub message: String,
55
56    /// Computed status relative to the "today" date used during scanning.
57    pub status: Status,
58
59    /// Owner inferred from git blame when no explicit `[owner]` bracket is present.
60    /// Populated only when `--blame` is passed; `None` otherwise.
61    #[serde(skip_serializing_if = "Option::is_none", default)]
62    pub blamed_owner: Option<String>,
63}
64
65impl Fuse {
66    /// Compute the number of days until (or since) expiry relative to `today`.
67    /// Positive means days remaining; negative means days overdue.
68    #[must_use]
69    pub fn days_from_today(&self, today: NaiveDate) -> i64 {
70        (self.date - today).num_days()
71    }
72
73    /// Returns true if this fuse has already detonated.
74    #[must_use]
75    pub fn is_detonated(&self) -> bool {
76        self.status == Status::Detonated
77    }
78
79    /// Returns true if this fuse is in the ticking window.
80    #[must_use]
81    pub fn is_ticking(&self) -> bool {
82        self.status == Status::Ticking
83    }
84
85    /// Returns true if this fuse is safely in the future.
86    #[must_use]
87    pub fn is_inert(&self) -> bool {
88        self.status == Status::Inert
89    }
90
91    /// Compute the status of a fuse given today's date and the fuse_days threshold.
92    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    /// Return a short location string like `src/main.rs:42`.
106    pub fn location(&self) -> String {
107        format!("{}:{}", self.file.display(), self.line)
108    }
109
110    /// Return the date formatted as YYYY-MM-DD.
111    pub fn date_str(&self) -> String {
112        self.date.format("%Y-%m-%d").to_string()
113    }
114}
115
116// --- Serde helpers for NaiveDate as "YYYY-MM-DD" string ---
117
118fn 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        // Exactly on the warn boundary — 14 days from now
165        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        // A fuse whose date IS today is considered ticking (date < today is false,
186        // but days_remaining == 0 which is <= fuse_days — so Ticking).
187        // Per spec, "deadline has passed" means strictly before today.
188        let today = date("2025-06-01");
189        let status = Fuse::compute_status(date("2025-06-01"), today, 0);
190        // With fuse_days=0, days_remaining==0 is <= 0 => Ticking
191        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        // Future date with no fuse window → Inert
274        let status = Fuse::compute_status(date("2025-06-02"), today, 0);
275        assert_eq!(status, Status::Inert);
276    }
277}