Skip to main content

braze_sync/diff/
orphan.rs

1//! `[ARCHIVED-YYYY-MM-DD]` orphan rename helper.
2//!
3//! Resources with no DELETE endpoint get renamed instead of dropped, so
4//! operators can still find them in the Braze dashboard. Functions are
5//! pure and date-injectable so tests can lock the format without
6//! coupling to the system clock.
7
8use chrono::NaiveDate;
9
10const PREFIX_OPEN: &str = "[ARCHIVED-";
11const PREFIX_CLOSE: &str = "] ";
12
13/// Apply the archive prefix to `original`. Idempotent: if the name
14/// already begins with `[ARCHIVED-YYYY-MM-DD] `, return it unchanged so
15/// running `apply --archive-orphans` twice does not produce
16/// `[ARCHIVED-2026-04-11] [ARCHIVED-2026-04-10] foo`.
17pub fn archive_name(today: NaiveDate, original: &str) -> String {
18    if is_archived(original) {
19        return original.to_string();
20    }
21    format!(
22        "{PREFIX_OPEN}{}{PREFIX_CLOSE}{original}",
23        today.format("%Y-%m-%d")
24    )
25}
26
27/// Whether `name` already carries an archive prefix in the canonical
28/// `[ARCHIVED-YYYY-MM-DD] ` shape. The date itself is parsed loosely
29/// (any 4-2-2 digit triple) so a name from a different day is still
30/// recognized as already-archived.
31pub fn is_archived(name: &str) -> bool {
32    let Some(rest) = name.strip_prefix(PREFIX_OPEN) else {
33        return false;
34    };
35    let Some(close_idx) = rest.find(PREFIX_CLOSE) else {
36        return false;
37    };
38    let date_part = &rest[..close_idx];
39    looks_like_date(date_part)
40}
41
42/// Match `NNNN-NN-NN` without validating the actual calendar date —
43/// permissive on purpose so a wrong-day prefix is still recognized as
44/// already-archived (otherwise we'd re-stamp on top of it).
45fn looks_like_date(s: &str) -> bool {
46    let bytes = s.as_bytes();
47    if bytes.len() != 10 {
48        return false;
49    }
50    bytes[4] == b'-'
51        && bytes[7] == b'-'
52        && bytes[..4].iter().all(|b| b.is_ascii_digit())
53        && bytes[5..7].iter().all(|b| b.is_ascii_digit())
54        && bytes[8..].iter().all(|b| b.is_ascii_digit())
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn day(y: i32, m: u32, d: u32) -> NaiveDate {
62        NaiveDate::from_ymd_opt(y, m, d).unwrap()
63    }
64
65    #[test]
66    fn archive_prefixes_a_clean_name() {
67        let renamed = archive_name(day(2026, 4, 11), "promo");
68        assert_eq!(renamed, "[ARCHIVED-2026-04-11] promo");
69    }
70
71    #[test]
72    fn archive_pads_single_digit_month_and_day() {
73        let renamed = archive_name(day(2026, 1, 9), "x");
74        assert_eq!(renamed, "[ARCHIVED-2026-01-09] x");
75    }
76
77    #[test]
78    fn archive_is_idempotent_on_already_archived_name() {
79        let once = archive_name(day(2026, 4, 11), "promo");
80        let twice = archive_name(day(2026, 4, 12), &once);
81        // Same date is preserved — we don't re-stamp the prefix.
82        assert_eq!(twice, once);
83    }
84
85    #[test]
86    fn is_archived_recognizes_canonical_prefix() {
87        assert!(is_archived("[ARCHIVED-2026-04-11] x"));
88        assert!(is_archived("[ARCHIVED-1999-12-31] legacy thing"));
89    }
90
91    #[test]
92    fn is_archived_rejects_close_but_wrong_shapes() {
93        assert!(!is_archived("ARCHIVED-2026-04-11 x"));
94        assert!(!is_archived("[ARCHIVED-2026/04/11] x"));
95        assert!(!is_archived("[ARCHIVED-26-4-11] x"));
96        assert!(!is_archived("[ARCHIVED-] x"));
97        assert!(!is_archived("[ARCHIVED-2026-04-11]x")); // missing space
98        assert!(!is_archived("plain name"));
99    }
100
101    #[test]
102    fn empty_original_is_still_archived() {
103        let renamed = archive_name(day(2026, 4, 11), "");
104        assert_eq!(renamed, "[ARCHIVED-2026-04-11] ");
105    }
106}