1use crate::annotation::{Fuse, Status};
4use chrono::NaiveDate;
5use std::io::{self, Write};
6
7pub 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
25pub 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
77pub fn print_armory(fuses: &[&Fuse], today: NaiveDate, oldest: bool) {
79 let stdout = io::stdout();
80 let mut handle = stdout.lock();
81 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}