1use chrono::NaiveDate;
38
39use crate::index::{GlobalIndex, IndexEntry};
40use crate::secret_path::SecretPath;
41
42pub const WARNING_WINDOW_DAYS: i64 = 7;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ExpiryWarning {
52 pub path: SecretPath,
54 pub kind: ExpiryWarningKind,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ExpiryWarningKind {
61 ExpiringSoon {
64 expires_at: String,
66 days_remaining: i64,
68 },
69 Expired {
72 expires_at: String,
74 days_overdue: i64,
76 },
77 RotationDueSoon {
80 last_rotated_at: String,
82 rotate_every_days: u32,
84 days_remaining: i64,
86 },
87 RotationOverdue {
89 last_rotated_at: String,
91 rotate_every_days: u32,
93 days_overdue: i64,
95 },
96}
97
98pub fn check_rotation_reminders(index: &GlobalIndex, today: NaiveDate) -> Vec<ExpiryWarning> {
114 let mut out = Vec::new();
115 for (path, entry) in index.iter() {
116 if let Some(kind) = check_expiry(entry, today) {
117 out.push(ExpiryWarning {
118 path: path.clone(),
119 kind,
120 });
121 }
122 if let Some(kind) = check_rotation(entry, today) {
123 out.push(ExpiryWarning {
124 path: path.clone(),
125 kind,
126 });
127 }
128 }
129 out
130}
131
132fn check_expiry(entry: &IndexEntry, today: NaiveDate) -> Option<ExpiryWarningKind> {
133 let raw = entry.expires_at.as_deref()?;
134 let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
135 let delta = (date - today).num_days();
136 if delta < 0 {
137 Some(ExpiryWarningKind::Expired {
138 expires_at: raw.to_owned(),
139 days_overdue: -delta,
140 })
141 } else if delta <= WARNING_WINDOW_DAYS {
142 Some(ExpiryWarningKind::ExpiringSoon {
143 expires_at: raw.to_owned(),
144 days_remaining: delta,
145 })
146 } else {
147 None
148 }
149}
150
151fn check_rotation(entry: &IndexEntry, today: NaiveDate) -> Option<ExpiryWarningKind> {
152 let raw = entry.last_rotated_at.as_deref()?;
153 let cadence = entry.rotate_every_days?;
154 let last = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
155 let due = last + chrono::Duration::days(cadence as i64);
156 let delta = (due - today).num_days();
157 if delta < 0 {
158 Some(ExpiryWarningKind::RotationOverdue {
159 last_rotated_at: raw.to_owned(),
160 rotate_every_days: cadence,
161 days_overdue: -delta,
162 })
163 } else if delta <= WARNING_WINDOW_DAYS {
164 Some(ExpiryWarningKind::RotationDueSoon {
165 last_rotated_at: raw.to_owned(),
166 rotate_every_days: cadence,
167 days_remaining: delta,
168 })
169 } else {
170 None
171 }
172}
173
174#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::index::{GlobalIndex, IndexEntry};
182
183 fn p(s: &str) -> SecretPath {
184 SecretPath::parse(s).unwrap()
185 }
186
187 fn date(s: &str) -> NaiveDate {
188 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
189 }
190
191 fn entry_with(
192 expires_at: Option<&str>,
193 last_rotated: Option<&str>,
194 cadence: Option<u32>,
195 ) -> IndexEntry {
196 IndexEntry {
197 expires_at: expires_at.map(str::to_owned),
198 last_rotated_at: last_rotated.map(str::to_owned),
199 rotate_every_days: cadence,
200 ..IndexEntry::default()
201 }
202 }
203
204 fn index_with_entry(path: &str, entry: IndexEntry) -> GlobalIndex {
205 let mut idx = GlobalIndex::new();
206 idx.insert(p(path), entry);
207 idx
208 }
209
210 #[test]
213 fn no_expires_at_no_warning() {
214 let idx = index_with_entry("a/b/c", entry_with(None, None, None));
215 let w = check_rotation_reminders(&idx, date("2026-05-01"));
216 assert!(w.is_empty());
217 }
218
219 #[test]
220 fn expires_at_far_future_no_warning() {
221 let idx = index_with_entry("a/b/c", entry_with(Some("2026-12-31"), None, None));
222 let w = check_rotation_reminders(&idx, date("2026-05-01"));
223 assert!(w.is_empty());
224 }
225
226 #[test]
227 fn expires_at_within_warning_window_emits_expiring_soon() {
228 let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-05"), None, None));
229 let w = check_rotation_reminders(&idx, date("2026-05-01"));
230 assert_eq!(w.len(), 1);
231 match &w[0].kind {
232 ExpiryWarningKind::ExpiringSoon {
233 expires_at,
234 days_remaining,
235 } => {
236 assert_eq!(expires_at, "2026-05-05");
237 assert_eq!(*days_remaining, 4);
238 }
239 other => panic!("expected ExpiringSoon, got {other:?}"),
240 }
241 }
242
243 #[test]
244 fn expires_at_at_exact_window_boundary_emits_expiring_soon() {
245 let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-08"), None, None));
247 let w = check_rotation_reminders(&idx, date("2026-05-01"));
248 match &w.first().unwrap().kind {
249 ExpiryWarningKind::ExpiringSoon { days_remaining, .. } => {
250 assert_eq!(*days_remaining, 7);
251 }
252 other => panic!("expected ExpiringSoon, got {other:?}"),
253 }
254 }
255
256 #[test]
257 fn expires_at_one_day_past_window_no_warning() {
258 let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-09"), None, None));
259 let w = check_rotation_reminders(&idx, date("2026-05-01"));
260 assert!(w.is_empty());
261 }
262
263 #[test]
264 fn expires_at_in_the_past_emits_expired_with_days_overdue() {
265 let idx = index_with_entry("a/b/c", entry_with(Some("2026-04-25"), None, None));
266 let w = check_rotation_reminders(&idx, date("2026-05-01"));
267 match &w.first().unwrap().kind {
268 ExpiryWarningKind::Expired {
269 expires_at,
270 days_overdue,
271 } => {
272 assert_eq!(expires_at, "2026-04-25");
273 assert_eq!(*days_overdue, 6);
274 }
275 other => panic!("expected Expired, got {other:?}"),
276 }
277 }
278
279 #[test]
280 fn unparseable_expires_at_yields_no_warning() {
281 let idx = index_with_entry("a/b/c", entry_with(Some("not-a-date"), None, None));
282 let w = check_rotation_reminders(&idx, date("2026-05-01"));
283 assert!(w.is_empty());
284 }
285
286 #[test]
289 fn no_rotation_metadata_no_warning() {
290 let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-04-01"), None));
291 let w = check_rotation_reminders(&idx, date("2026-05-01"));
292 assert!(w.is_empty());
293 }
294
295 #[test]
296 fn rotation_far_in_future_no_warning() {
297 let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(90)));
300 let w = check_rotation_reminders(&idx, date("2026-01-15"));
301 assert!(w.is_empty());
302 }
303
304 #[test]
305 fn rotation_due_soon_emits_warning() {
306 let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(30)));
309 let w = check_rotation_reminders(&idx, date("2026-01-29"));
310 match &w.first().unwrap().kind {
311 ExpiryWarningKind::RotationDueSoon {
312 last_rotated_at,
313 rotate_every_days,
314 days_remaining,
315 } => {
316 assert_eq!(last_rotated_at, "2026-01-01");
317 assert_eq!(*rotate_every_days, 30);
318 assert_eq!(*days_remaining, 2);
319 }
320 other => panic!("expected RotationDueSoon, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn rotation_overdue_emits_warning() {
326 let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(30)));
329 let w = check_rotation_reminders(&idx, date("2026-02-10"));
330 match &w.first().unwrap().kind {
331 ExpiryWarningKind::RotationOverdue { days_overdue, .. } => {
332 assert_eq!(*days_overdue, 10);
333 }
334 other => panic!("expected RotationOverdue, got {other:?}"),
335 }
336 }
337
338 #[test]
341 fn both_timers_within_window_emit_two_warnings() {
342 let idx = index_with_entry(
345 "a/b/c",
346 entry_with(Some("2026-05-05"), Some("2026-04-04"), Some(30)),
347 );
348 let w = check_rotation_reminders(&idx, date("2026-05-01"));
349 assert_eq!(w.len(), 2);
350 assert_eq!(w[0].path, w[1].path);
352 assert!(matches!(w[0].kind, ExpiryWarningKind::ExpiringSoon { .. }));
354 assert!(matches!(
355 w[1].kind,
356 ExpiryWarningKind::RotationDueSoon { .. }
357 ));
358 }
359
360 #[test]
363 fn record_expiry_updates_existing_entry_returns_true() {
364 let mut idx = index_with_entry("a/b/c", entry_with(None, None, None));
365 let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
366 assert!(changed);
367 let entry = idx.get(&p("a/b/c")).unwrap();
368 assert_eq!(entry.expires_at.as_deref(), Some("2026-08-01"));
369 }
370
371 #[test]
372 fn record_expiry_unchanged_returns_false() {
373 let mut idx = index_with_entry("a/b/c", entry_with(Some("2026-08-01"), None, None));
374 let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
375 assert!(!changed, "no-op write should report unchanged");
376 }
377
378 #[test]
379 fn record_expiry_missing_path_returns_false() {
380 let mut idx = GlobalIndex::new();
381 let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
382 assert!(!changed);
383 }
384
385 #[test]
386 fn record_rotation_updates_existing_entry() {
387 let mut idx = index_with_entry("a/b/c", entry_with(None, None, None));
388 let changed = idx.record_rotation(&p("a/b/c"), "2026-05-01");
389 assert!(changed);
390 let entry = idx.get(&p("a/b/c")).unwrap();
391 assert_eq!(entry.last_rotated_at.as_deref(), Some("2026-05-01"));
392 }
393
394 #[test]
397 fn save_to_round_trip_preserves_entries() {
398 let dir = tempfile::TempDir::new().unwrap();
399 let path = dir.path().join("subdir").join("index.toml");
400 let mut idx = index_with_entry(
401 "team/x/y",
402 IndexEntry {
403 expires_at: Some("2026-08-01".to_owned()),
404 description: Some("test".to_owned()),
405 ..IndexEntry::default()
406 },
407 );
408 idx.record_expiry(&p("team/x/y"), "2026-09-01");
409 idx.save_to(&path).unwrap();
410
411 let reloaded = GlobalIndex::load_from(&path).unwrap();
412 let entry = reloaded.get(&p("team/x/y")).unwrap();
413 assert_eq!(entry.expires_at.as_deref(), Some("2026-09-01"));
414 assert_eq!(entry.description.as_deref(), Some("test"));
415 }
416}