Skip to main content

timebomb/
armory.rs

1//! Priority view for fuses that need attention soon.
2
3use crate::annotation::{Fuse, Status};
4use chrono::NaiveDate;
5use std::io::{self, Write};
6
7/// Return detonated and ticking fuses ordered by urgency.
8pub fn select_armory_fuses(fuses: &[Fuse], today: NaiveDate, limit: usize) -> Vec<&Fuse> {
9    let mut selected: Vec<&Fuse> = fuses
10        .iter()
11        .filter(|fuse| matches!(fuse.status, Status::Detonated | Status::Ticking))
12        .collect();
13
14    selected.sort_unstable_by(|a, b| {
15        armory_status_order(&a.status)
16            .cmp(&armory_status_order(&b.status))
17            .then(a.days_from_today(today).cmp(&b.days_from_today(today)))
18            .then(a.file.cmp(&b.file))
19            .then(a.line.cmp(&b.line))
20    });
21    selected.truncate(limit);
22    selected
23}
24
25/// Write the armory view to the supplied writer.
26pub fn print_armory_to_writer<W: Write>(
27    fuses: &[&Fuse],
28    today: NaiveDate,
29    oldest: bool,
30    mut writer: W,
31) -> io::Result<()> {
32    let heading = if oldest {
33        "Most volatile fuse"
34    } else {
35        "Most volatile fuses"
36    };
37    writeln!(writer, "{heading}")?;
38
39    if fuses.is_empty() {
40        writeln!(writer)?;
41        writeln!(writer, "Magazine is quiet.")?;
42        return Ok(());
43    }
44
45    for (idx, fuse) in fuses.iter().enumerate() {
46        let days = fuse.days_from_today(today);
47        let status = match fuse.status {
48            Status::Detonated => "DETONATED",
49            Status::Ticking => "TICKING",
50            Status::Inert => "INERT",
51        };
52        let delta = if days < 0 {
53            format!("{}d overdue", days.abs())
54        } else {
55            format!("{}d left", days)
56        };
57
58        writeln!(
59            writer,
60            "{}. {:<9} {:>11}  {}:{}",
61            idx + 1,
62            status,
63            delta,
64            fuse.file.display(),
65            fuse.line
66        )?;
67        writeln!(writer, "   {}", fuse.annotation_text())?;
68
69        if idx + 1 < fuses.len() {
70            writeln!(writer)?;
71        }
72    }
73
74    Ok(())
75}
76
77/// Print the armory view to stdout.
78pub fn print_armory(fuses: &[&Fuse], today: NaiveDate, oldest: bool) {
79    let stdout = io::stdout();
80    let mut handle = stdout.lock();
81    // stdout write failures are not expected in normal CLI use; keep the public
82    // command path simple like the other terminal renderers in this crate.
83    let _ = print_armory_to_writer(fuses, today, oldest, &mut handle);
84}
85
86fn armory_status_order(status: &Status) -> u8 {
87    match status {
88        Status::Detonated => 0,
89        Status::Ticking => 1,
90        Status::Inert => 2,
91    }
92}
93
94trait FuseAnnotationText {
95    fn annotation_text(&self) -> String;
96}
97
98impl FuseAnnotationText for Fuse {
99    fn annotation_text(&self) -> String {
100        match self.owner.as_deref() {
101            Some(owner) => format!(
102                "{}[{}][{}]: {}",
103                self.tag,
104                self.date_str(),
105                owner,
106                self.message
107            ),
108            None => format!("{}[{}]: {}", self.tag, self.date_str(), self.message),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::path::PathBuf;
117
118    fn date(s: &str) -> NaiveDate {
119        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
120    }
121
122    fn fuse(file: &str, line: usize, expiry: &str, status: Status, message: &str) -> Fuse {
123        Fuse {
124            file: PathBuf::from(file),
125            line,
126            tag: "TODO".to_string(),
127            date: date(expiry),
128            owner: None,
129            message: message.to_string(),
130            status,
131            blamed_owner: None,
132        }
133    }
134
135    #[test]
136    fn test_select_armory_fuses_ranks_detonated_then_ticking() {
137        let today = date("2026-04-18");
138        let fuses = vec![
139            fuse("soon.rs", 1, "2026-04-19", Status::Ticking, "one day"),
140            fuse("old.rs", 1, "2026-04-01", Status::Detonated, "old"),
141            fuse("older.rs", 1, "2026-03-01", Status::Detonated, "older"),
142            fuse("later.rs", 1, "2026-04-25", Status::Ticking, "later"),
143            fuse("safe.rs", 1, "2026-12-01", Status::Inert, "safe"),
144        ];
145
146        let selected = select_armory_fuses(&fuses, today, 10);
147
148        assert_eq!(selected.len(), 4);
149        assert_eq!(selected[0].file, PathBuf::from("older.rs"));
150        assert_eq!(selected[1].file, PathBuf::from("old.rs"));
151        assert_eq!(selected[2].file, PathBuf::from("soon.rs"));
152        assert_eq!(selected[3].file, PathBuf::from("later.rs"));
153    }
154
155    #[test]
156    fn test_select_armory_fuses_honors_limit() {
157        let today = date("2026-04-18");
158        let fuses = vec![
159            fuse("a.rs", 1, "2026-04-01", Status::Detonated, "a"),
160            fuse("b.rs", 1, "2026-04-02", Status::Detonated, "b"),
161            fuse("c.rs", 1, "2026-04-03", Status::Detonated, "c"),
162        ];
163
164        let selected = select_armory_fuses(&fuses, today, 2);
165
166        assert_eq!(selected.len(), 2);
167    }
168
169    #[test]
170    fn test_print_armory_to_writer_empty() {
171        let today = date("2026-04-18");
172        let mut out = Vec::new();
173
174        print_armory_to_writer(&[], today, false, &mut out).unwrap();
175
176        let text = String::from_utf8(out).unwrap();
177        assert!(text.contains("Most volatile fuses"));
178        assert!(text.contains("Magazine is quiet."));
179    }
180
181    #[test]
182    fn test_print_armory_to_writer_includes_annotation() {
183        let today = date("2026-04-18");
184        let mut item = fuse(
185            "src/auth.rs",
186            42,
187            "2026-04-01",
188            Status::Detonated,
189            "remove fallback",
190        );
191        item.owner = Some("alice".to_string());
192        let selected = vec![&item];
193        let mut out = Vec::new();
194
195        print_armory_to_writer(&selected, today, false, &mut out).unwrap();
196
197        let text = String::from_utf8(out).unwrap();
198        assert!(text.contains("DETONATED"));
199        assert!(text.contains("17d overdue"));
200        assert!(text.contains("src/auth.rs:42"));
201        assert!(text.contains("TODO[2026-04-01][alice]: remove fallback"));
202    }
203
204    #[test]
205    fn test_print_armory_to_writer_oldest_heading() {
206        let today = date("2026-04-18");
207        let item = fuse("src/auth.rs", 42, "2026-04-01", Status::Detonated, "old");
208        let selected = vec![&item];
209        let mut out = Vec::new();
210
211        print_armory_to_writer(&selected, today, true, &mut out).unwrap();
212
213        let text = String::from_utf8(out).unwrap();
214        assert!(text.starts_with("Most volatile fuse\n"));
215    }
216}