Skip to main content

braze_sync/diff/
tag.rs

1//! Tag diff types and registry comparison.
2//!
3//! Tags are GitOps-only: Braze has no public REST API for managing them.
4//! `diff` compares the local registry (`tags/registry.yaml`) against the
5//! union of tag names referenced on local resources (content_blocks,
6//! email_templates) and reports the symmetric drift:
7//!
8//! - `ReferencedButUnregistered`: a resource references this tag but it
9//!   is not declared in the registry. Apply would fail at the Braze API
10//!   layer with `400 Tags could not be found`. This is **actionable**
11//!   for the operator (add to registry + create in Braze dashboard) and
12//!   blocks `apply` pre-flight.
13//! - `RegisteredButUnreferenced`: declared in the registry but no
14//!   resource currently uses it. Informational — may be a deletion the
15//!   operator hasn't pruned, or a tag staged for upcoming use.
16//! - `Unchanged`: declared and referenced, in sync.
17//!
18//! `apply` cannot mutate tags directly, so no diff variant is "actionable"
19//! in the apply-mutation sense. The diff is consumed by validate and by
20//! `apply`'s pre-flight check.
21
22use crate::resource::{Tag, TagRegistry};
23use std::collections::{BTreeMap, BTreeSet};
24
25#[derive(Debug, Clone)]
26pub struct TagDiff {
27    pub name: String,
28    pub op: TagOp,
29    pub hints: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
33pub enum TagOp {
34    /// Referenced by at least one local resource but missing from the
35    /// registry. Apply pre-flight will block on this.
36    ReferencedButUnregistered,
37    /// Declared in the registry but no local resource references it.
38    RegisteredButUnreferenced,
39    Unchanged,
40}
41
42impl TagDiff {
43    pub fn has_changes(&self) -> bool {
44        !matches!(self.op, TagOp::Unchanged)
45    }
46}
47
48/// Compare a local registry against a set of tag names actually
49/// referenced by local resources.
50///
51/// `referenced` is the union of `tags:` arrays across content_blocks,
52/// email_templates, etc. `local` is the registry file. Either may be
53/// `None` / empty.
54pub fn diff(local: Option<&TagRegistry>, referenced: &BTreeSet<String>) -> Vec<TagDiff> {
55    let registry_by_name: BTreeMap<&str, &Tag> = local
56        .map(|r| {
57            let mut map = BTreeMap::new();
58            for t in &r.tags {
59                if map.insert(t.name.as_str(), t).is_some() {
60                    tracing::warn!(
61                        name = t.name.as_str(),
62                        "duplicate tag name in local registry; \
63                         last entry wins (run `validate` to catch this)"
64                    );
65                }
66            }
67            map
68        })
69        .unwrap_or_default();
70
71    let mut all_names: BTreeSet<&str> = BTreeSet::new();
72    all_names.extend(registry_by_name.keys().copied());
73    all_names.extend(referenced.iter().map(String::as_str));
74
75    let mut diffs = Vec::new();
76    for name in all_names {
77        let in_registry = registry_by_name.contains_key(name);
78        let is_referenced = referenced.contains(name);
79        let op = match (in_registry, is_referenced) {
80            (true, true) => TagOp::Unchanged,
81            (true, false) => TagOp::RegisteredButUnreferenced,
82            (false, true) => TagOp::ReferencedButUnregistered,
83            (false, false) => unreachable!("name came from one of the two maps"),
84        };
85        diffs.push(TagDiff {
86            name: name.to_string(),
87            op,
88            hints: Vec::new(),
89        });
90    }
91
92    diffs
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::resource::Tag;
99
100    fn registry(names: &[&str]) -> TagRegistry {
101        TagRegistry {
102            tags: names
103                .iter()
104                .map(|n| Tag {
105                    name: (*n).to_string(),
106                    description: None,
107                })
108                .collect(),
109        }
110    }
111
112    fn referenced(names: &[&str]) -> BTreeSet<String> {
113        names.iter().map(|s| s.to_string()).collect()
114    }
115
116    #[test]
117    fn no_diff_when_registry_matches_references() {
118        let r = registry(&["a", "b"]);
119        let used = referenced(&["a", "b"]);
120        let diffs = diff(Some(&r), &used);
121        assert_eq!(diffs.len(), 2);
122        assert!(diffs.iter().all(|d| !d.has_changes()));
123    }
124
125    #[test]
126    fn referenced_but_unregistered_tag_is_flagged() {
127        let r = registry(&["a"]);
128        let used = referenced(&["a", "missing"]);
129        let diffs = diff(Some(&r), &used);
130        let missing = diffs.iter().find(|d| d.name == "missing").unwrap();
131        assert!(matches!(missing.op, TagOp::ReferencedButUnregistered));
132    }
133
134    #[test]
135    fn registered_but_unreferenced_tag_is_flagged() {
136        let r = registry(&["a", "orphan"]);
137        let used = referenced(&["a"]);
138        let diffs = diff(Some(&r), &used);
139        let orphan = diffs.iter().find(|d| d.name == "orphan").unwrap();
140        assert!(matches!(orphan.op, TagOp::RegisteredButUnreferenced));
141    }
142
143    #[test]
144    fn missing_registry_treats_all_references_as_unregistered() {
145        let used = referenced(&["a", "b"]);
146        let diffs = diff(None, &used);
147        assert_eq!(diffs.len(), 2);
148        assert!(diffs
149            .iter()
150            .all(|d| matches!(d.op, TagOp::ReferencedButUnregistered)));
151    }
152
153    #[test]
154    fn empty_when_neither_side_has_tags() {
155        let diffs = diff(None, &referenced(&[]));
156        assert!(diffs.is_empty());
157    }
158
159    #[test]
160    fn diffs_are_sorted_by_name() {
161        let r = registry(&["zebra", "apple"]);
162        let used = referenced(&["mango"]);
163        let diffs = diff(Some(&r), &used);
164        let names: Vec<&str> = diffs.iter().map(|d| d.name.as_str()).collect();
165        assert_eq!(names, vec!["apple", "mango", "zebra"]);
166    }
167}