1use chrono::{DateTime, Utc};
2
3use super::{Board, Card, Policies};
4
5pub fn format_age(created: DateTime<Utc>, now: DateTime<Utc>) -> String {
7 let days = (now - created).num_days().max(0) as u64;
8 if days == 0 {
9 "new".to_string()
10 } else if days < 14 {
11 format!("{days}d")
12 } else if days < 60 {
13 format!("{}w", days / 7)
14 } else {
15 format!("{}mo", days / 30)
16 }
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Staleness {
22 Fresh,
24 Stale,
26 VeryStale,
28}
29
30pub fn staleness(card: &Card, policies: &Policies, now: DateTime<Utc>) -> Staleness {
32 if policies.stale_days == 0 {
33 return Staleness::Fresh;
34 }
35 let days_since_update = (now - card.updated).num_days().max(0) as u32;
36 if days_since_update >= policies.stale_days * 2 {
37 Staleness::VeryStale
38 } else if days_since_update >= policies.stale_days {
39 Staleness::Stale
40 } else {
41 Staleness::Fresh
42 }
43}
44
45pub fn card_staleness_label(card: &Card, policies: &Policies, now: DateTime<Utc>) -> &'static str {
52 match staleness(card, policies, now) {
53 Staleness::VeryStale => "very stale",
54 Staleness::Stale => "stale",
55 Staleness::Fresh => {
56 if card.created.date_naive() == now.date_naive() {
57 "new"
58 } else {
59 "normal"
60 }
61 }
62 }
63}
64
65pub fn should_auto_close(card: &Card, policies: &Policies, now: DateTime<Utc>) -> bool {
67 if policies.auto_close_days == 0 {
68 return false;
69 }
70 let days_since_update = (now - card.updated).num_days().max(0) as u32;
71 days_since_update >= policies.auto_close_days
72}
73
74#[derive(Debug)]
76pub struct ClosedCard {
77 pub id: String,
78 pub title: String,
79 pub from_col_slug: String,
80}
81
82pub fn run_auto_close(board: &mut Board, now: DateTime<Utc>) -> Vec<ClosedCard> {
86 let auto_close_days = board.policies.auto_close_days;
87 if auto_close_days == 0 {
88 return Vec::new();
89 }
90
91 let target_col = match board
93 .columns
94 .iter()
95 .position(|c| c.slug == board.policies.auto_close_target)
96 {
97 Some(idx) => idx,
98 None => return Vec::new(),
99 };
100
101 let mut to_move: Vec<(usize, usize)> = Vec::new();
103 for (col_idx, col) in board.columns.iter().enumerate() {
104 if col_idx == target_col {
105 continue;
106 }
107 for (card_idx, card) in col.cards.iter().enumerate() {
108 if should_auto_close(card, &board.policies, now) {
109 to_move.push((col_idx, card_idx));
110 }
111 }
112 }
113
114 let mut closed_ids = Vec::new();
116 for &(col_idx, card_idx) in to_move.iter().rev() {
117 let from_col_slug = board.columns[col_idx].slug.clone();
118 let card = board.columns[col_idx].cards.remove(card_idx);
119 closed_ids.push(ClosedCard { id: card.id.clone(), title: card.title.clone(), from_col_slug });
120 board.columns[target_col].cards.push(card);
121 }
122
123 for col in &mut board.columns {
125 col.sort_cards();
126 }
127
128 closed_ids
129}
130
131#[derive(Debug)]
133pub struct ArchivedCard {
134 pub id: String,
135 pub title: String,
136 pub from_col_slug: String,
139}
140
141pub fn run_auto_archive(board: &mut Board, now: DateTime<Utc>) -> Vec<ArchivedCard> {
149 let days = board.policies.archive_after_days;
150 if days == 0 {
151 return Vec::new();
152 }
153 let threshold = chrono::Duration::days(days as i64);
154
155 let done_idx = board.columns.iter().position(|c| c.slug == "done");
156 let archive_idx = board.columns.iter().position(|c| c.slug == "archive");
157 let (Some(done_idx), Some(archive_idx)) = (done_idx, archive_idx) else {
158 return Vec::new();
159 };
160
161 let to_archive: Vec<usize> = board.columns[done_idx]
164 .cards
165 .iter()
166 .enumerate()
167 .filter(|(_, card)| {
168 let age_start = card.completed.unwrap_or(card.updated);
169 now.signed_duration_since(age_start) >= threshold
170 })
171 .map(|(i, _)| i)
172 .rev()
173 .collect();
174
175 let done_slug = board.columns[done_idx].slug.clone();
176 let mut archived = Vec::new();
177 for idx in to_archive {
178 let card = board.columns[done_idx].cards.remove(idx);
179 archived.push(ArchivedCard {
180 id: card.id.clone(),
181 title: card.title.clone(),
182 from_col_slug: done_slug.clone(),
183 });
184 board.columns[archive_idx].cards.push(card);
186 }
187
188 board.columns[done_idx].sort_cards();
189 board.columns[archive_idx].sort_cards();
190
191 archived
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use chrono::TimeZone;
198
199 #[test]
200 fn test_format_age() {
201 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
202
203 let created_today = now;
204 assert_eq!(format_age(created_today, now), "new");
205
206 let created_3d = Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap();
207 assert_eq!(format_age(created_3d, now), "3d");
208
209 let created_2w = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
210 assert_eq!(format_age(created_2w, now), "2w");
211
212 let created_3mo = Utc.with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap();
213 assert_eq!(format_age(created_3mo, now), "3mo");
214 }
215
216 #[test]
217 fn test_staleness() {
218 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
219 let policies = Policies {
220 stale_days: 7,
221 ..Default::default()
222 };
223
224 let fresh_card = Card {
225 updated: Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap(),
226 ..Card::new("001".into(), "Test".into())
227 };
228 assert_eq!(staleness(&fresh_card, &policies, now), Staleness::Fresh);
229
230 let stale_card = Card {
231 updated: Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(),
232 ..Card::new("002".into(), "Test".into())
233 };
234 assert_eq!(staleness(&stale_card, &policies, now), Staleness::Stale);
235
236 let very_stale_card = Card {
237 updated: Utc.with_ymd_and_hms(2025, 5, 28, 12, 0, 0).unwrap(),
238 ..Card::new("003".into(), "Test".into())
239 };
240 assert_eq!(
241 staleness(&very_stale_card, &policies, now),
242 Staleness::VeryStale
243 );
244 }
245
246 #[test]
247 fn test_should_auto_close() {
248 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
249 let policies = Policies {
250 auto_close_days: 30,
251 ..Default::default()
252 };
253
254 let recent = Card {
255 updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(),
256 ..Card::new("001".into(), "Recent".into())
257 };
258 assert!(!should_auto_close(&recent, &policies, now));
259
260 let old = Card {
261 updated: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
262 ..Card::new("002".into(), "Old".into())
263 };
264 assert!(should_auto_close(&old, &policies, now));
265
266 let disabled = Policies {
267 auto_close_days: 0,
268 ..Default::default()
269 };
270 assert!(!should_auto_close(&old, &disabled, now));
271 }
272
273 fn make_column(slug: &str, name: &str, cards: Vec<Card>) -> super::super::Column {
276 super::super::Column {
277 slug: slug.into(),
278 name: name.into(),
279 order: 0,
280 wip_limit: None,
281 hidden: false,
282 cards,
283 }
284 }
285
286 fn make_board(columns: Vec<super::super::Column>, policies: Policies) -> Board {
287 Board {
288 name: "Test".into(),
289 next_card_id: 100,
290 policies,
291 sync_branch: None,
292
293 nerd_font: false,
294 created_at: None,
295 columns,
296 }
297 }
298
299 #[test]
300 fn run_auto_close_moves_stale_cards() {
301 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
302 let mut stale_card = Card::new("1".into(), "Stale".into());
303 stale_card.updated = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(); let policies = Policies {
306 auto_close_days: 30,
307 auto_close_target: "archive".to_string(),
308 ..Default::default()
309 };
310
311 let mut board = make_board(vec![
312 make_column("backlog", "Backlog", vec![stale_card]),
313 make_column("in-progress", "In Progress", vec![]),
314 make_column("archive", "Archive", vec![]),
315 ], policies);
316
317 let closed = run_auto_close(&mut board, now);
318 assert_eq!(closed.len(), 1);
319 assert_eq!(closed[0].id, "1");
320 assert_eq!(closed[0].from_col_slug, "backlog");
321 assert_eq!(board.columns[0].cards.len(), 0); assert_eq!(board.columns[2].cards.len(), 1); assert_eq!(board.columns[2].cards[0].id, "1");
324 }
325
326 #[test]
327 fn run_auto_close_skips_fresh_cards() {
328 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
329 let mut fresh_card = Card::new("1".into(), "Fresh".into());
330 fresh_card.updated = Utc.with_ymd_and_hms(2025, 6, 10, 12, 0, 0).unwrap(); let policies = Policies {
333 auto_close_days: 30,
334 auto_close_target: "archive".to_string(),
335 ..Default::default()
336 };
337
338 let mut board = make_board(vec![
339 make_column("backlog", "Backlog", vec![fresh_card]),
340 make_column("archive", "Archive", vec![]),
341 ], policies);
342
343 let closed = run_auto_close(&mut board, now);
344 assert!(closed.is_empty());
345 assert_eq!(board.columns[0].cards.len(), 1); }
347
348 #[test]
349 fn run_auto_close_skips_cards_in_target_column() {
350 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
351 let mut old_card = Card::new("1".into(), "Old".into());
352 old_card.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
353
354 let policies = Policies {
355 auto_close_days: 30,
356 auto_close_target: "archive".to_string(),
357 ..Default::default()
358 };
359
360 let mut board = make_board(vec![
361 make_column("backlog", "Backlog", vec![]),
362 make_column("archive", "Archive", vec![old_card]),
363 ], policies);
364
365 let closed = run_auto_close(&mut board, now);
366 assert!(closed.is_empty());
367 assert_eq!(board.columns[1].cards.len(), 1); }
369
370 #[test]
371 fn run_auto_close_disabled_when_zero() {
372 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
373 let mut stale_card = Card::new("1".into(), "Stale".into());
374 stale_card.updated = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
375
376 let policies = Policies {
377 auto_close_days: 0,
378 auto_close_target: "archive".to_string(),
379 ..Default::default()
380 };
381
382 let mut board = make_board(vec![
383 make_column("backlog", "Backlog", vec![stale_card]),
384 make_column("archive", "Archive", vec![]),
385 ], policies);
386
387 let closed = run_auto_close(&mut board, now);
388 assert!(closed.is_empty());
389 assert_eq!(board.columns[0].cards.len(), 1); }
391
392 #[test]
393 fn run_auto_close_no_target_column_returns_empty() {
394 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
395 let mut stale_card = Card::new("1".into(), "Stale".into());
396 stale_card.updated = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
397
398 let policies = Policies {
399 auto_close_days: 30,
400 auto_close_target: "nonexistent".to_string(),
401 ..Default::default()
402 };
403
404 let mut board = make_board(vec![
405 make_column("backlog", "Backlog", vec![stale_card]),
406 ], policies);
407
408 let closed = run_auto_close(&mut board, now);
409 assert!(closed.is_empty());
410 }
411
412 #[test]
413 fn run_auto_close_multiple_stale_across_columns() {
414 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
415 let mut stale1 = Card::new("1".into(), "Stale1".into());
416 stale1.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
417 let mut stale2 = Card::new("2".into(), "Stale2".into());
418 stale2.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
419
420 let policies = Policies {
421 auto_close_days: 30,
422 auto_close_target: "archive".to_string(),
423 ..Default::default()
424 };
425
426 let mut board = make_board(vec![
427 make_column("backlog", "Backlog", vec![stale1]),
428 make_column("in-progress", "In Progress", vec![stale2]),
429 make_column("archive", "Archive", vec![]),
430 ], policies);
431
432 let closed = run_auto_close(&mut board, now);
433 assert_eq!(closed.len(), 2);
434 assert_eq!(board.columns[2].cards.len(), 2); }
436
437 #[test]
440 fn format_age_boundary_14_days() {
441 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
442 let d13 = Utc.with_ymd_and_hms(2025, 6, 2, 12, 0, 0).unwrap();
444 assert_eq!(format_age(d13, now), "13d");
445 let d14 = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
447 assert_eq!(format_age(d14, now), "2w");
448 }
449
450 #[test]
451 fn format_age_boundary_60_days() {
452 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
453 let d59 = Utc.with_ymd_and_hms(2025, 4, 17, 12, 0, 0).unwrap();
455 assert_eq!(format_age(d59, now), "8w");
456 let d60 = Utc.with_ymd_and_hms(2025, 4, 16, 12, 0, 0).unwrap();
458 assert_eq!(format_age(d60, now), "2mo");
459 }
460
461 #[test]
462 fn format_age_future_date_returns_new() {
463 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
464 let future = Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
465 assert_eq!(format_age(future, now), "new");
466 }
467
468 #[test]
471 fn staleness_disabled_when_zero() {
472 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
473 let policies = Policies { stale_days: 0, ..Default::default() };
474 let old = Card {
475 updated: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
476 ..Card::new("1".into(), "Ancient".into())
477 };
478 assert_eq!(staleness(&old, &policies, now), Staleness::Fresh);
479 }
480
481 #[test]
482 fn staleness_boundary_exact_threshold() {
483 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
484 let policies = Policies { stale_days: 7, ..Default::default() };
485 let card = Card {
487 updated: Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap(),
488 ..Card::new("1".into(), "Test".into())
489 };
490 assert_eq!(staleness(&card, &policies, now), Staleness::Stale);
491 }
492
493 #[test]
494 fn staleness_boundary_exact_double() {
495 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
496 let policies = Policies { stale_days: 7, ..Default::default() };
497 let card = Card {
499 updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(),
500 ..Card::new("1".into(), "Test".into())
501 };
502 assert_eq!(staleness(&card, &policies, now), Staleness::VeryStale);
503 }
504
505 #[test]
508 fn run_auto_close_closed_card_title_is_correct() {
509 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
510 let mut stale = Card::new("7".into(), "Important Work".into());
511 stale.updated = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
512
513 let policies = Policies {
514 auto_close_days: 30,
515 auto_close_target: "archive".to_string(),
516 ..Default::default()
517 };
518 let mut board = make_board(vec![
519 make_column("backlog", "Backlog", vec![stale]),
520 make_column("archive", "Archive", vec![]),
521 ], policies);
522
523 let closed = run_auto_close(&mut board, now);
524 assert_eq!(closed.len(), 1);
525 assert_eq!(closed[0].title, "Important Work");
526 }
527
528 #[test]
529 fn run_auto_close_at_exact_boundary_is_moved() {
530 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
531 let mut card = Card::new("1".into(), "Boundary".into());
533 card.updated = Utc.with_ymd_and_hms(2025, 5, 16, 12, 0, 0).unwrap();
534
535 let policies = Policies {
536 auto_close_days: 30,
537 auto_close_target: "archive".to_string(),
538 ..Default::default()
539 };
540 let mut board = make_board(vec![
541 make_column("backlog", "Backlog", vec![card]),
542 make_column("archive", "Archive", vec![]),
543 ], policies);
544
545 let closed = run_auto_close(&mut board, now);
546 assert_eq!(closed.len(), 1, "card at exact threshold should be moved");
547 }
548
549 #[test]
550 fn run_auto_close_one_day_short_not_moved() {
551 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
552 let mut card = Card::new("1".into(), "Almost Stale".into());
554 card.updated = Utc.with_ymd_and_hms(2025, 5, 17, 12, 0, 0).unwrap();
555
556 let policies = Policies {
557 auto_close_days: 30,
558 auto_close_target: "archive".to_string(),
559 ..Default::default()
560 };
561 let mut board = make_board(vec![
562 make_column("backlog", "Backlog", vec![card]),
563 make_column("archive", "Archive", vec![]),
564 ], policies);
565
566 let closed = run_auto_close(&mut board, now);
567 assert!(closed.is_empty(), "card 29 days old should not be moved at 30-day threshold");
568 assert_eq!(board.columns[0].cards.len(), 1);
569 }
570
571 #[test]
572 fn run_auto_close_multiple_stale_same_column_all_moved() {
573 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
574 let old = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
575 let mut c1 = Card::new("1".into(), "First".into());
576 c1.updated = old;
577 let mut c2 = Card::new("2".into(), "Second".into());
578 c2.updated = old;
579 let mut c3 = Card::new("3".into(), "Third".into());
580 c3.updated = old;
581
582 let policies = Policies {
583 auto_close_days: 30,
584 auto_close_target: "archive".to_string(),
585 ..Default::default()
586 };
587 let mut board = make_board(vec![
588 make_column("backlog", "Backlog", vec![c1, c2, c3]),
589 make_column("archive", "Archive", vec![]),
590 ], policies);
591
592 let closed = run_auto_close(&mut board, now);
593 assert_eq!(closed.len(), 3);
594 assert_eq!(board.columns[0].cards.len(), 0);
595 assert_eq!(board.columns[1].cards.len(), 3);
596 }
597
598 #[test]
599 fn run_auto_close_from_col_slug_reflects_source_column() {
600 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
601 let old = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
602 let mut card_a = Card::new("1".into(), "A".into());
603 card_a.updated = old;
604 let mut card_b = Card::new("2".into(), "B".into());
605 card_b.updated = old;
606
607 let policies = Policies {
608 auto_close_days: 30,
609 auto_close_target: "archive".to_string(),
610 ..Default::default()
611 };
612 let mut board = make_board(vec![
613 make_column("backlog", "Backlog", vec![card_a]),
614 make_column("in-progress", "In Progress", vec![card_b]),
615 make_column("archive", "Archive", vec![]),
616 ], policies);
617
618 let mut closed = run_auto_close(&mut board, now);
619 assert_eq!(closed.len(), 2);
620 closed.sort_by(|a, b| a.id.cmp(&b.id));
621 assert_eq!(closed[0].from_col_slug, "backlog");
622 assert_eq!(closed[1].from_col_slug, "in-progress");
623 }
624
625 #[test]
626 fn run_auto_close_empty_board_no_panic() {
627 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
628 let policies = Policies {
629 auto_close_days: 30,
630 auto_close_target: "archive".to_string(),
631 ..Default::default()
632 };
633 let mut board = make_board(vec![], policies);
634 let closed = run_auto_close(&mut board, now);
635 assert!(closed.is_empty());
636 }
637
638 #[test]
641 fn run_auto_archive_moves_card_from_done_to_archive() {
642 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
643 let completed = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(); let mut card = Card::new("1".into(), "Done task".into());
645 card.completed = Some(completed);
646
647 let policies = Policies { archive_after_days: 7, ..Default::default() };
648 let mut board = make_board(vec![
649 make_column("done", "Done", vec![card]),
650 make_column("archive", "Archive", vec![]),
651 ], policies);
652
653 let archived = run_auto_archive(&mut board, now);
654 assert_eq!(archived.len(), 1);
655 assert_eq!(archived[0].id, "1");
656 assert_eq!(board.columns[0].cards.len(), 0, "done column should be empty");
657 assert_eq!(board.columns[1].cards.len(), 1, "archive column should have one card");
658 assert_eq!(board.columns[1].cards[0].id, "1");
659 }
660
661 #[test]
662 fn run_auto_archive_disabled_when_zero() {
663 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
664 let old = Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
665 let mut card = Card::new("1".into(), "Old task".into());
666 card.completed = Some(old);
667
668 let policies = Policies { archive_after_days: 0, ..Default::default() };
669 let mut board = make_board(vec![
670 make_column("done", "Done", vec![card]),
671 make_column("archive", "Archive", vec![]),
672 ], policies);
673
674 let archived = run_auto_archive(&mut board, now);
675 assert!(archived.is_empty());
676 assert_eq!(board.columns[0].cards.len(), 1, "card should remain in done");
677 }
678
679 #[test]
680 fn run_auto_archive_at_exact_threshold_is_moved() {
681 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
682 let completed = Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap();
684 let mut card = Card::new("1".into(), "Boundary".into());
685 card.completed = Some(completed);
686
687 let policies = Policies { archive_after_days: 7, ..Default::default() };
688 let mut board = make_board(vec![
689 make_column("done", "Done", vec![card]),
690 make_column("archive", "Archive", vec![]),
691 ], policies);
692
693 let archived = run_auto_archive(&mut board, now);
694 assert_eq!(archived.len(), 1, "card at exact 7-day threshold should be archived");
695 }
696
697 #[test]
698 fn run_auto_archive_one_day_short_not_moved() {
699 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
700 let completed = Utc.with_ymd_and_hms(2025, 6, 9, 12, 0, 0).unwrap();
702 let mut card = Card::new("1".into(), "Almost ready".into());
703 card.completed = Some(completed);
704
705 let policies = Policies { archive_after_days: 7, ..Default::default() };
706 let mut board = make_board(vec![
707 make_column("done", "Done", vec![card]),
708 make_column("archive", "Archive", vec![]),
709 ], policies);
710
711 let archived = run_auto_archive(&mut board, now);
712 assert!(archived.is_empty(), "card 6 days old should not be archived at 7-day threshold");
713 assert_eq!(board.columns[0].cards.len(), 1, "card should still be in done");
714 }
715
716 #[test]
717 fn run_auto_archive_preserves_completed_timestamp() {
718 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
719 let completed = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap(); let mut card = Card::new("1".into(), "Task".into());
721 card.completed = Some(completed);
722
723 let policies = Policies { archive_after_days: 7, ..Default::default() };
724 let mut board = make_board(vec![
725 make_column("done", "Done", vec![card]),
726 make_column("archive", "Archive", vec![]),
727 ], policies);
728
729 run_auto_archive(&mut board, now);
730
731 let archived_card = &board.columns[1].cards[0];
732 assert_eq!(
733 archived_card.completed,
734 Some(completed),
735 "completed timestamp must be preserved after auto-archiving"
736 );
737 }
738
739 #[test]
740 fn run_auto_archive_uses_completed_over_updated() {
741 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
742 let completed = Utc.with_ymd_and_hms(2025, 6, 12, 12, 0, 0).unwrap();
744 let updated = Utc.with_ymd_and_hms(2025, 5, 16, 12, 0, 0).unwrap();
746 let mut card = Card::new("1".into(), "Recently completed".into());
747 card.completed = Some(completed);
748 card.updated = updated;
749
750 let policies = Policies { archive_after_days: 7, ..Default::default() };
751 let mut board = make_board(vec![
752 make_column("done", "Done", vec![card]),
753 make_column("archive", "Archive", vec![]),
754 ], policies);
755
756 let archived = run_auto_archive(&mut board, now);
757 assert!(
758 archived.is_empty(),
759 "completed (3 days ago) takes priority over updated (30 days ago); must not be archived"
760 );
761 assert_eq!(board.columns[0].cards.len(), 1, "card should remain in done");
762 }
763
764 #[test]
765 fn run_auto_archive_falls_back_to_updated_when_no_completed() {
766 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
767 let updated = Utc.with_ymd_and_hms(2025, 6, 5, 12, 0, 0).unwrap();
769 let mut card = Card::new("1".into(), "No completed".into());
770 card.updated = updated;
771 let policies = Policies { archive_after_days: 7, ..Default::default() };
774 let mut board = make_board(vec![
775 make_column("done", "Done", vec![card]),
776 make_column("archive", "Archive", vec![]),
777 ], policies);
778
779 let archived = run_auto_archive(&mut board, now);
780 assert_eq!(archived.len(), 1, "should fall back to updated when completed is None");
781 }
782
783 #[test]
784 fn run_auto_archive_only_moves_from_done_not_other_columns() {
785 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
786 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(); let mut backlog_card = Card::new("1".into(), "Backlog old".into());
789 backlog_card.updated = old;
790 let mut done_card = Card::new("2".into(), "Done old".into());
792 done_card.completed = Some(old);
793
794 let policies = Policies { archive_after_days: 7, ..Default::default() };
795 let mut board = make_board(vec![
796 make_column("backlog", "Backlog", vec![backlog_card]),
797 make_column("done", "Done", vec![done_card]),
798 make_column("archive", "Archive", vec![]),
799 ], policies);
800
801 let archived = run_auto_archive(&mut board, now);
802 assert_eq!(archived.len(), 1, "only done-column cards should be archived");
803 assert_eq!(archived[0].id, "2");
804 assert_eq!(board.columns[0].cards.len(), 1, "backlog card should be untouched");
805 assert_eq!(board.columns[1].cards.len(), 0, "done card should be moved");
806 assert_eq!(board.columns[2].cards.len(), 1, "archive should have the done card");
807 }
808
809 #[test]
810 fn run_auto_archive_no_done_column_returns_empty() {
811 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
812 let policies = Policies { archive_after_days: 7, ..Default::default() };
813 let mut board = make_board(vec![
814 make_column("backlog", "Backlog", vec![]),
815 make_column("archive", "Archive", vec![]),
816 ], policies);
817
818 let archived = run_auto_archive(&mut board, now);
819 assert!(archived.is_empty());
820 }
821
822 #[test]
823 fn run_auto_archive_no_archive_column_returns_empty() {
824 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
825 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
826 let mut card = Card::new("1".into(), "Old done".into());
827 card.completed = Some(old);
828
829 let policies = Policies { archive_after_days: 7, ..Default::default() };
830 let mut board = make_board(vec![
831 make_column("done", "Done", vec![card]),
832 ], policies);
833
834 let archived = run_auto_archive(&mut board, now);
835 assert!(archived.is_empty());
836 assert_eq!(board.columns[0].cards.len(), 1, "card must stay when no archive column exists");
837 }
838
839 #[test]
840 fn run_auto_archive_multiple_done_cards_all_old_all_moved() {
841 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
842 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
843 let mut c1 = Card::new("1".into(), "First".into());
844 c1.completed = Some(old);
845 let mut c2 = Card::new("2".into(), "Second".into());
846 c2.completed = Some(old);
847 let mut c3 = Card::new("3".into(), "Third".into());
848 c3.completed = Some(old);
849
850 let policies = Policies { archive_after_days: 7, ..Default::default() };
851 let mut board = make_board(vec![
852 make_column("done", "Done", vec![c1, c2, c3]),
853 make_column("archive", "Archive", vec![]),
854 ], policies);
855
856 let archived = run_auto_archive(&mut board, now);
857 assert_eq!(archived.len(), 3, "all three old cards should be archived");
858 assert_eq!(board.columns[0].cards.len(), 0, "done column should be empty");
859 assert_eq!(board.columns[1].cards.len(), 3, "archive column should have 3 cards");
860 }
861
862 #[test]
863 fn run_auto_archive_mixed_old_and_young_only_old_moved() {
864 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
865 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
866 let recent = Utc.with_ymd_and_hms(2025, 6, 13, 12, 0, 0).unwrap(); let mut old_card = Card::new("1".into(), "Old".into());
868 old_card.completed = Some(old);
869 let mut new_card = Card::new("2".into(), "New".into());
870 new_card.completed = Some(recent);
871
872 let policies = Policies { archive_after_days: 7, ..Default::default() };
873 let mut board = make_board(vec![
874 make_column("done", "Done", vec![old_card, new_card]),
875 make_column("archive", "Archive", vec![]),
876 ], policies);
877
878 let archived = run_auto_archive(&mut board, now);
879 assert_eq!(archived.len(), 1, "only the old card should be archived");
880 assert_eq!(archived[0].id, "1");
881 assert_eq!(board.columns[0].cards.len(), 1, "recent card should remain in done");
882 assert_eq!(board.columns[0].cards[0].id, "2", "the remaining card should be the recent one");
883 }
884
885 #[test]
886 fn run_auto_archive_returns_correct_id_and_title() {
887 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
888 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
889 let mut card = Card::new("042".into(), "Very Important Task".into());
890 card.completed = Some(old);
891
892 let policies = Policies { archive_after_days: 7, ..Default::default() };
893 let mut board = make_board(vec![
894 make_column("done", "Done", vec![card]),
895 make_column("archive", "Archive", vec![]),
896 ], policies);
897
898 let archived = run_auto_archive(&mut board, now);
899 assert_eq!(archived.len(), 1);
900 assert_eq!(archived[0].id, "042");
901 assert_eq!(archived[0].title, "Very Important Task");
902 }
903
904 #[test]
905 fn run_auto_archive_from_col_slug_is_done_slug() {
906 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
907 let old = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
908 let mut card = Card::new("1".into(), "Task".into());
909 card.completed = Some(old);
910
911 let policies = Policies { archive_after_days: 7, ..Default::default() };
912 let mut board = make_board(vec![
913 make_column("done", "Done", vec![card]),
914 make_column("archive", "Archive", vec![]),
915 ], policies);
916
917 let archived = run_auto_archive(&mut board, now);
918 assert_eq!(archived.len(), 1);
919 assert_eq!(archived[0].from_col_slug, "done");
920 }
921
922 #[test]
923 fn run_auto_archive_does_not_update_updated_field() {
924 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
925 let completed = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
926 let updated = Utc.with_ymd_and_hms(2025, 4, 20, 12, 0, 0).unwrap();
927 let mut card = Card::new("1".into(), "Task".into());
928 card.completed = Some(completed);
929 card.updated = updated;
930
931 let policies = Policies { archive_after_days: 7, ..Default::default() };
932 let mut board = make_board(vec![
933 make_column("done", "Done", vec![card]),
934 make_column("archive", "Archive", vec![]),
935 ], policies);
936
937 run_auto_archive(&mut board, now);
938
939 let archived_card = &board.columns[1].cards[0];
940 assert_eq!(
941 archived_card.updated, updated,
942 "archiving must not change updated (touch() is intentionally skipped)"
943 );
944 }
945
946 #[test]
947 fn run_auto_archive_preserves_started_timestamp() {
948 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
949 let completed = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
950 let started = Utc.with_ymd_and_hms(2025, 4, 1, 12, 0, 0).unwrap();
951 let mut card = Card::new("1".into(), "Task".into());
952 card.completed = Some(completed);
953 card.started = Some(started);
954
955 let policies = Policies { archive_after_days: 7, ..Default::default() };
956 let mut board = make_board(vec![
957 make_column("done", "Done", vec![card]),
958 make_column("archive", "Archive", vec![]),
959 ], policies);
960
961 run_auto_archive(&mut board, now);
962
963 let archived_card = &board.columns[1].cards[0];
964 assert_eq!(
965 archived_card.started,
966 Some(started),
967 "started timestamp must be preserved after auto-archiving"
968 );
969 }
970
971 #[test]
972 fn run_auto_archive_empty_done_column_returns_empty() {
973 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
974 let policies = Policies { archive_after_days: 7, ..Default::default() };
975 let mut board = make_board(vec![
976 make_column("done", "Done", vec![]),
977 make_column("archive", "Archive", vec![]),
978 ], policies);
979
980 let archived = run_auto_archive(&mut board, now);
981 assert!(archived.is_empty());
982 }
983
984 #[test]
985 fn run_auto_archive_future_completed_not_moved() {
986 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
987 let completed = Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
989 let mut card = Card::new("1".into(), "Future done".into());
990 card.completed = Some(completed);
991
992 let policies = Policies { archive_after_days: 7, ..Default::default() };
993 let mut board = make_board(vec![
994 make_column("done", "Done", vec![card]),
995 make_column("archive", "Archive", vec![]),
996 ], policies);
997
998 let archived = run_auto_archive(&mut board, now);
999 assert!(archived.is_empty(), "card with future completed timestamp must not be archived");
1000 assert_eq!(board.columns[0].cards.len(), 1);
1001 }
1002
1003 #[test]
1006 fn card_staleness_label_returns_new_for_card_created_today() {
1007 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1008 let policies = Policies { stale_days: 7, ..Default::default() };
1009 let card = Card {
1010 created: now,
1011 updated: now,
1012 ..Card::new("1".into(), "Test".into())
1013 };
1014 assert_eq!(card_staleness_label(&card, &policies, now), "new");
1015 }
1016
1017 #[test]
1018 fn card_staleness_label_returns_normal_for_fresh_non_new() {
1019 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1020 let policies = Policies { stale_days: 7, ..Default::default() };
1021 let card = Card {
1022 created: Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap(),
1023 updated: Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap(),
1024 ..Card::new("1".into(), "Test".into())
1025 };
1026 assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1027 }
1028
1029 #[test]
1030 fn card_staleness_label_returns_stale() {
1031 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1032 let policies = Policies { stale_days: 7, ..Default::default() };
1033 let card = Card {
1034 created: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
1035 updated: Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap(), ..Card::new("1".into(), "Test".into())
1037 };
1038 assert_eq!(card_staleness_label(&card, &policies, now), "stale");
1039 }
1040
1041 #[test]
1042 fn card_staleness_label_returns_very_stale() {
1043 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1044 let policies = Policies { stale_days: 7, ..Default::default() };
1045 let card = Card {
1046 created: Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap(),
1047 updated: Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap(), ..Card::new("1".into(), "Test".into())
1049 };
1050 assert_eq!(card_staleness_label(&card, &policies, now), "very stale");
1051 }
1052
1053 #[test]
1054 fn card_staleness_label_stale_days_zero_new_card() {
1055 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1056 let policies = Policies { stale_days: 0, ..Default::default() };
1057 let card = Card {
1058 created: now,
1059 updated: now,
1060 ..Card::new("1".into(), "Test".into())
1061 };
1062 assert_eq!(card_staleness_label(&card, &policies, now), "new");
1063 }
1064
1065 #[test]
1066 fn card_staleness_label_stale_days_zero_old_card() {
1067 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
1068 let policies = Policies { stale_days: 0, ..Default::default() };
1069 let card = Card {
1070 created: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
1071 updated: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
1072 ..Card::new("1".into(), "Test".into())
1073 };
1074 assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1076 }
1077
1078 #[test]
1079 fn card_staleness_label_utc_day_boundary() {
1080 let created = Utc.with_ymd_and_hms(2025, 6, 14, 23, 59, 59).unwrap();
1082 let now = Utc.with_ymd_and_hms(2025, 6, 15, 0, 0, 1).unwrap();
1083 let policies = Policies { stale_days: 7, ..Default::default() };
1084 let card = Card {
1085 created,
1086 updated: created,
1087 ..Card::new("1".into(), "Test".into())
1088 };
1089 assert_eq!(card_staleness_label(&card, &policies, now), "normal");
1091 }
1092}